Skip to content

Commit 0e823dc

Browse files
committed
feat: Add settings GUI for overlay configuration and custom key mapping, and refactor build process to generate both debug and release versions.
1 parent a05e82c commit 0e823dc

8 files changed

Lines changed: 193 additions & 63 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ config.ini
66
build/
77
dist/
88
*.spec
9-
*.zip
9+
*.zip
10+
RainingKeysPython-debug

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ RainingKeysPython/
7575
### Debug vs Release Build
7676
You can toggle the console window visibility via `config.ini`:** window (controls settings).
7777
3. Use the Config window to move the overlay or change speed/direction live.
78-
4. Press the configured keys (Default: `a`, `s`, `l`, `;`) to see the visualization.
78+
4. **Configure Lanes**:
79+
- Click "Record Lane Keys" in the config window.
80+
- Press the keys you want to bind (e.g., `Z`, `X`, `.`, `/`).
81+
- Click "Stop Recording" to save. The overlay uses these keys immediately.
7982
5. **To Exit**: Close the Config window or press `Ctrl+C` in the terminal.
8083

8184
## Configuration
@@ -91,6 +94,7 @@ You can edit this file manually or use the **GUI Settings Window**.
9194
| `Visual` | `fall_direction` | `down` or `up`. |
9295
| `Position` | `x` | Overlay X position (pixels). |
9396
| `Position` | `y` | Overlay Y position (pixels). |
97+
| `lanes` | `keys` | Comma-separated list of keys (e.g., `'z','x','.'`). |
9498

9599
*Note: Key mappings and colors are currently defined in `core/config.py`.*
96100

@@ -100,7 +104,7 @@ You can edit this file manually or use the **GUI Settings Window**.
100104
- [x] Save/Load config from ini
101105
- [ ] Multi-monitor support
102106
- [ ] Custom color
103-
- [ ] Custom key mapping
107+
- [x] Custom key mapping
104108

105109
## Disclaimer
106110

build.py

Lines changed: 61 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,7 @@
1010
EXECUTABLE_NAME = 'RainingKeysPython'
1111
MAIN_SCRIPT = 'main.py'
1212

13-
def read_config():
14-
"""
15-
Reads debug_mode from config.ini or config.cfg.
16-
Returns boolean: True (Debug), False (Release).
17-
Defaults to False if not specified.
18-
"""
19-
config = configparser.ConfigParser()
20-
21-
# Try to read config files
22-
found_files = config.read(CONFIG_FILES)
23-
if not found_files:
24-
print(f"Warning: No config file found ({'/'.join(CONFIG_FILES)}). Defaulting to Release mode.")
25-
return False
26-
27-
# Check for debug_mode in [General] section (or others if widely used)
28-
# We prioritize [General] > [Debug] > global search if needed, but [General] is standard.
29-
if config.has_section('General'):
30-
if config.has_option('General', 'debug_mode'):
31-
return config.getboolean('General', 'debug_mode')
32-
33-
# Fallback: check if it's in a [Debug] section
34-
if config.has_section('Debug'):
35-
if config.has_option('Debug', 'debug_mode'):
36-
return config.getboolean('Debug', 'debug_mode')
37-
38-
print("Info: 'debug_mode' not found in config. Defaulting to Release mode.")
39-
return False
13+
4014

4115
def clean_directories():
4216
"""Removes build and dist directories."""
@@ -53,6 +27,7 @@ def build(debug_mode):
5327
cmd = [
5428
'pyinstaller',
5529
'--onedir',
30+
'--noconfirm',
5631
'--clean',
5732
'--name', EXECUTABLE_NAME,
5833
MAIN_SCRIPT
@@ -111,32 +86,74 @@ def create_zip(debug_mode):
11186
shutil.make_archive(zip_name, 'zip', root_dir=OUTPUT_DIR, base_dir=EXECUTABLE_NAME)
11287
print(f"Zip created: {zip_name}.zip")
11388

114-
def main():
115-
# 1. Clean previous builds
116-
clean_directories()
117-
118-
# 2. Determine mode
119-
debug_mode = read_config()
120-
121-
# 3. Build
89+
def update_config_debug_mode(debug_mode):
90+
"""Updates config.ini to match the current build mode."""
91+
# We read the config to preserve other settings, but force debug_mode
92+
config = configparser.ConfigParser()
93+
config.read(CONFIG_FILES)
94+
95+
# Ensure sections exist
96+
if not config.has_section('General'):
97+
if config.has_section('Debug'):
98+
# Legacy support if user uses [Debug]
99+
config.set('Debug', 'debug_mode', str(debug_mode))
100+
else:
101+
config.add_section('General')
102+
config.set('General', 'debug_mode', str(debug_mode))
103+
else:
104+
config.set('General', 'debug_mode', str(debug_mode))
105+
106+
# Write back
107+
# We pick the first available config file to write to, usually config.ini
108+
target_cfg = CONFIG_FILES[0]
109+
with open(target_cfg, 'w') as f:
110+
config.write(f)
111+
112+
def run_build_cycle(debug_mode):
113+
print(f"\n>>> Starting {'DEBUG' if debug_mode else 'RELEASE'} Build Cycle <<<")
114+
115+
# Update config file so the built executable reads the correct mode at runtime
116+
# AND so that the copy_config_to_dist puts the correct config in the dist folder
117+
update_config_debug_mode(debug_mode)
118+
119+
# Build
122120
try:
123121
build(debug_mode)
124122
except subprocess.CalledProcessError as e:
125123
print(f"Error during build: {e}")
126124
sys.exit(1)
125+
126+
# Copy Config
127+
copy_config_to_dist()
128+
129+
# Zip
130+
create_zip(debug_mode)
131+
132+
def main():
133+
# 1. Clean previous builds once at the start
134+
clean_directories()
135+
136+
# 2. Check dependencies
137+
try:
138+
subprocess.call(['pyinstaller', '--version'], stdout=subprocess.DEVNULL)
127139
except FileNotFoundError:
128140
print("Error: 'pyinstaller' not found. Please install it via 'pip install pyinstaller'.")
129141
sys.exit(1)
130142

131-
# 4. Copy config
132-
copy_config_to_dist()
133-
134-
# 5. Zip
135-
create_zip(debug_mode)
136-
137-
print("\nBuild and packaging complete!")
138-
print(f"Output folder: {os.path.join(OUTPUT_DIR, EXECUTABLE_NAME)}")
139-
print(f"Zip file: {EXECUTABLE_NAME + ('-debug' if debug_mode else '')}.zip")
143+
# 3. Release Build (Normal)
144+
run_build_cycle(debug_mode=False)
145+
146+
# 4. Debug Build
147+
# We clean build/ between runs to ensure clean state, but NOT dist/ (so we keep the zips)
148+
if os.path.exists(BUILD_DIR):
149+
shutil.rmtree(BUILD_DIR)
150+
151+
run_build_cycle(debug_mode=True)
152+
153+
print("\nAll builds complete!")
154+
print(f"Artifacts located in project root:")
155+
print(f" - {EXECUTABLE_NAME}.zip")
156+
print(f" - {EXECUTABLE_NAME}-debug.zip")
140157

141158
if __name__ == "__main__":
142159
main()

core/gui.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1-
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, QComboBox, QGroupBox
2-
from PySide6.QtCore import Qt
1+
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, QComboBox, QGroupBox, QPushButton
2+
from PySide6.QtCore import Qt, Slot
3+
from PySide6.QtGui import QColor, QPalette
34
from .settings_manager import SettingsManager
5+
from .config import Config
46

57
class SettingsWindow(QWidget):
68
def __init__(self, settings_manager: SettingsManager):
79
super().__init__()
810
self.settings = settings_manager
9-
self.init_ui()
1011
self.setWindowTitle("RainingKeys Config")
11-
self.resize(300, 250)
12+
self.resize(300, 350)
13+
14+
# Recording State
15+
self.is_recording = False
16+
self.recorded_keys = []
17+
18+
self.init_ui()
1219

1320
def init_ui(self):
1421
layout = QVBoxLayout()
@@ -62,6 +69,25 @@ def init_ui(self):
6269
vis_group.setLayout(vis_layout)
6370
layout.addWidget(vis_group)
6471

72+
# Lane Configuration Group
73+
lane_group = QGroupBox("Lane Configuration")
74+
lane_layout = QVBoxLayout()
75+
76+
self.lbl_lane_status = QLabel("Current Keys: " + str(len(Config.LANE_MAP)))
77+
self.lbl_lane_status.setWordWrap(True)
78+
lane_layout.addWidget(self.lbl_lane_status)
79+
80+
self.btn_record = QPushButton("Record Lane Keys")
81+
self.btn_record.clicked.connect(self.toggle_recording)
82+
lane_layout.addWidget(self.btn_record)
83+
84+
self.lbl_instruction = QLabel("Click 'Record', then press keys in order.\nClick 'Stop' when done.")
85+
self.lbl_instruction.setStyleSheet("color: gray; font-size: 10px;")
86+
lane_layout.addWidget(self.lbl_instruction)
87+
88+
lane_group.setLayout(lane_layout)
89+
layout.addWidget(lane_group)
90+
6591
layout.addStretch()
6692
self.setLayout(layout)
6793

@@ -74,3 +100,35 @@ def on_visual_changed(self):
74100
self.settings.set('Visual', 'fall_direction', self.combo_dir.currentText())
75101
self.settings.set('Visual', 'scroll_speed', self.spin_speed.value())
76102
self.settings.save()
103+
104+
def toggle_recording(self):
105+
if not self.is_recording:
106+
# Start Recording
107+
self.is_recording = True
108+
self.recorded_keys = []
109+
self.btn_record.setText("Stop Recording")
110+
self.lbl_lane_status.setText("Recording... Press keys!")
111+
self.lbl_lane_status.setStyleSheet("color: red; font-weight: bold;")
112+
else:
113+
# Stop Recording
114+
self.is_recording = False
115+
self.btn_record.setText("Record Lane Keys")
116+
self.lbl_lane_status.setStyleSheet("")
117+
118+
if self.recorded_keys:
119+
# Save
120+
self.settings.save_lanes(self.recorded_keys)
121+
self.lbl_lane_status.setText(f"Saved {len(self.recorded_keys)} lane keys.")
122+
123+
# Update overlay if needed? save_lanes emits settings_changed which overlay listens to.
124+
else:
125+
self.lbl_lane_status.setText("No keys recorded. Canceled.")
126+
127+
@Slot(str)
128+
def handle_raw_key(self, key_str):
129+
"""Slot to receive raw keys from InputMonitor."""
130+
if self.is_recording:
131+
# Avoid duplicates if desired
132+
if key_str not in self.recorded_keys:
133+
self.recorded_keys.append(key_str)
134+
self.lbl_lane_status.setText(f"Recorded: {', '.join(self.recorded_keys)}")

core/input_mon.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class InputWorker(QObject):
1010
"""
1111
key_pressed = Signal(int, float) # lane_index, timestamp
1212
key_released = Signal(int, float) # lane_index, timestamp
13+
raw_key_pressed = Signal(str) # raw_key_string
1314

1415
def __init__(self):
1516
super().__init__()
@@ -47,6 +48,8 @@ def on_press(self, key):
4748
if k_str in self.active_keys:
4849
return
4950

51+
self.raw_key_pressed.emit(k_str)
52+
5053
if k_str in Config.LANE_MAP:
5154
self.active_keys.add(k_str)
5255
timestamp = time.perf_counter()
@@ -73,12 +76,14 @@ class InputMonitor(QThread):
7376
"""
7477
key_pressed = Signal(int, float)
7578
key_released = Signal(int, float)
79+
raw_key_pressed = Signal(str)
7680

7781
def __init__(self):
7882
super().__init__()
7983
self.worker = InputWorker()
8084
self.worker.key_pressed.connect(self.key_pressed.emit)
8185
self.worker.key_released.connect(self.key_released.emit)
86+
self.worker.raw_key_pressed.connect(self.raw_key_pressed.emit)
8287

8388
def run(self):
8489
self.worker.start_monitoring()

core/overlay.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,11 @@ def init_ui(self):
9999
self.setAttribute(Qt.WA_TranslucentBackground)
100100
self.setAttribute(Qt.WA_TransparentForMouseEvents)
101101

102-
# Initial calculation for window size
103-
# We need enough width for all lanes
104-
max_lane = 0
105-
if Config.LANE_MAP:
106-
max_lane = max(Config.LANE_MAP.values())
107-
108-
# Width: Start Offset + (Max Lane Index + 1) * Lane Width + Extra Padding
109-
width = Config.LANE_START_X + ((max_lane + 1) * Config.LANE_WIDTH) + 50
110-
height = QApplication.primaryScreen().size().height()
102+
self.setAttribute(Qt.WA_TransparentForMouseEvents)
111103

112-
self.resize(width, height)
113-
# Move to configured position
114-
self.move(self.settings.overlay_x, self.settings.overlay_y)
104+
self.update_layout()
105+
106+
# Win32 Click-through
115107

116108
# Win32 Click-through
117109
if HAS_WIN32:
@@ -125,6 +117,21 @@ def init_ui(self):
125117
def on_settings_changed(self):
126118
# Move window live when settings change
127119
self.move(self.settings.overlay_x, self.settings.overlay_y)
120+
self.update_layout() # Recalculate size if lanes changed
121+
122+
def update_layout(self):
123+
"""Recalculates window size based on current lanes."""
124+
max_lane = 0
125+
if Config.LANE_MAP:
126+
max_lane = max(Config.LANE_MAP.values())
127+
else:
128+
max_lane = 0 # Fallback
129+
130+
# Width: Start Offset + (Max Lane Index + 1) * Lane Width + Extra Padding
131+
width = Config.LANE_START_X + ((max_lane + 1) * Config.LANE_WIDTH) + 50
132+
height = QApplication.primaryScreen().size().height()
133+
134+
self.resize(width, height)
128135

129136
def handle_input(self, lane_index, timestamp):
130137
"""Slot called when input monitor detects a key press."""

core/settings_manager.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ def _load(self):
2424
if not self.config.has_section('Position'):
2525
self.config.add_section('Position')
2626
changed = True
27+
if not self.config.has_section('lanes'):
28+
self.config.add_section('lanes')
29+
changed = True
2730

2831
# Defaults
2932
if not self.config.has_option('Visual', 'scroll_speed'):
@@ -39,6 +42,15 @@ def _load(self):
3942
self.config.set('Position', 'y', str(0))
4043
changed = True
4144

45+
if not self.config.has_option('lanes', 'keys'):
46+
# Default keys
47+
default_keys = "'a','s','l',';'"
48+
self.config.set('lanes', 'keys', default_keys)
49+
changed = True
50+
51+
# Apply lanes to Config
52+
self._apply_lanes()
53+
4254
if changed:
4355
self.save()
4456

@@ -64,6 +76,27 @@ def save(self):
6476
self.config.write(f)
6577
self.settings_changed.emit()
6678

79+
def _apply_lanes(self):
80+
"""Reads keys from config and updates Config.LANE_MAP."""
81+
if self.config.has_option('lanes', 'keys'):
82+
keys_str = self.config.get('lanes', 'keys')
83+
key_list = [k.strip() for k in keys_str.split(',') if k.strip()]
84+
85+
# Rebuild map
86+
Config.LANE_MAP.clear()
87+
for idx, k in enumerate(key_list):
88+
Config.LANE_MAP[k] = idx
89+
90+
def save_lanes(self, key_list):
91+
"""Saves a list of key strings to config."""
92+
keys_str = ",".join(key_list)
93+
if not self.config.has_section('lanes'):
94+
self.config.add_section('lanes')
95+
self.config.set('lanes', 'keys', keys_str)
96+
self.save() # Saves to file
97+
self._apply_lanes() # Updates runtime config
98+
self.settings_changed.emit() # Notify UI/Overlay
99+
67100
# Properties for easy access
68101
@property
69102
def scroll_speed(self):

0 commit comments

Comments
 (0)