Skip to content

Commit 654bcbe

Browse files
committed
feat: Introduce GUI for live configuration and persistent settings via config.ini, enabling customizable overlay position and fall direction.
1 parent bd3735e commit 654bcbe

6 files changed

Lines changed: 290 additions & 76 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ __pycache__/
33
implementation_plan.md
44
task.md
55
walkthrough.md
6+
config.ini

README.md

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ RainingKeys is a purely external overlay application that visualizes keyboard in
1919
## Features
2020

2121
- **External Overlay**: Runs as a transparent, always-on-top, click-through window over any game.
22+
- **Graphic Interface**: Live configuration window to adjust settings on the fly.
23+
- **Positioning**: Configurable X/Y overlay position.
24+
- **Fall Direction**: Supports both Down (Classic) and Up (Reverse) fall directions.
2225
- **Accurate Timing**: Uses high-resolution monotonic clocks (`time.perf_counter`) for smooth, jitter-free falling animation.
2326
- **Lane System**: Configurable key-to-lane mapping (e.g., WASD, Space, Enter).
2427
- **Long Press Support**: Visualizes held keys with variable-length bars.
@@ -64,23 +67,30 @@ RainingKeysPython/
6467
```bash
6568
python main.py
6669
```
67-
2. The overlay will appear (default: full screen transparent window).
68-
3. Press the configured keys (Default: `a`, `s`, `l`, `;`) to see the visualization.
69-
4. The Debug Overlay in the top-left corner shows FPS and object pool stats.
70-
5. **To Exit**: Press `Ctrl+C` in the terminal window.
70+
2. **Two windows will appear**:
71+
- The transparent **Overlay** (shows the bars).
72+
- The **RainingKeys Config** window (controls settings).
73+
3. Use the Config window to move the overlay or change speed/direction live.
74+
4. Press the configured keys (Default: `a`, `s`, `l`, `;`) to see the visualization.
75+
5. **To Exit**: Close the Config window or press `Ctrl+C` in the terminal.
7176

7277
## Configuration
7378

74-
Edit `core/config.py` to customize the overlay:
79+
## Configuration
80+
81+
Settings are stored in `config.ini` (automatically created on first run).
82+
You can edit this file manually or use the **GUI Settings Window**.
83+
84+
### Config Options
85+
86+
| Section | Parameter | Description |
87+
| :--- | :--- | :--- |
88+
| `Visual` | `scroll_speed` | Falling speed in pixels per second. |
89+
| `Visual` | `fall_direction` | `down` or `up`. |
90+
| `Position` | `x` | Overlay X position (pixels). |
91+
| `Position` | `y` | Overlay Y position (pixels). |
7592

76-
| Parameter | Description |
77-
| :--- | :--- |
78-
| `SCROLL_SPEED` | Falling speed in pixels per second. |
79-
| `LANE_MAP` | Dictionary mapping specific keys (e.g., `'a'`, `'Key.space'`) to lane indices. |
80-
| `BAR_WIDTH` | Visual width of the falling notes. |
81-
| `INPUT_LATENCY_OFFSET` | Seconds to offset rendering (useful for audio sync). |
82-
| `MAX_BARS` | Soft limit for the object pool (prevents memory leaks). |
83-
| `COLORS` | RGBA values for bars and text. |
93+
*Note: Key mappings and colors are currently defined in `core/config.py`.*
8494

8595
## Roadmap
8696

core/gui.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, QComboBox, QGroupBox
2+
from PySide6.QtCore import Qt
3+
from .settings_manager import SettingsManager
4+
5+
class SettingsWindow(QWidget):
6+
def __init__(self, settings_manager: SettingsManager):
7+
super().__init__()
8+
self.settings = settings_manager
9+
self.init_ui()
10+
self.setWindowTitle("RainingKeys Config")
11+
self.resize(300, 250)
12+
13+
def init_ui(self):
14+
layout = QVBoxLayout()
15+
16+
# Position Group
17+
pos_group = QGroupBox("Overlay Position")
18+
pos_layout = QHBoxLayout()
19+
20+
self.spin_x = QSpinBox()
21+
self.spin_x.setRange(-10000, 10000)
22+
self.spin_x.setPrefix("X: ")
23+
self.spin_x.setValue(self.settings.overlay_x)
24+
self.spin_x.valueChanged.connect(self.on_pos_changed)
25+
26+
self.spin_y = QSpinBox()
27+
self.spin_y.setRange(-10000, 10000)
28+
self.spin_y.setPrefix("Y: ")
29+
self.spin_y.setValue(self.settings.overlay_y)
30+
self.spin_y.valueChanged.connect(self.on_pos_changed)
31+
32+
pos_layout.addWidget(self.spin_x)
33+
pos_layout.addWidget(self.spin_y)
34+
pos_group.setLayout(pos_layout)
35+
layout.addWidget(pos_group)
36+
37+
# Visual Group
38+
vis_group = QGroupBox("Visual Settings")
39+
vis_layout = QVBoxLayout()
40+
41+
# Direction
42+
dir_layout = QHBoxLayout()
43+
dir_layout.addWidget(QLabel("Fall Direction:"))
44+
self.combo_dir = QComboBox()
45+
self.combo_dir.addItems(["down", "up"])
46+
self.combo_dir.setCurrentText(self.settings.fall_direction)
47+
self.combo_dir.currentTextChanged.connect(self.on_visual_changed)
48+
dir_layout.addWidget(self.combo_dir)
49+
vis_layout.addLayout(dir_layout)
50+
51+
# Speed
52+
speed_layout = QHBoxLayout()
53+
speed_layout.addWidget(QLabel("Scroll Speed (px/s):"))
54+
self.spin_speed = QSpinBox()
55+
self.spin_speed.setRange(100, 5000)
56+
self.spin_speed.setSingleStep(50)
57+
self.spin_speed.setValue(self.settings.scroll_speed)
58+
self.spin_speed.valueChanged.connect(self.on_visual_changed)
59+
speed_layout.addWidget(self.spin_speed)
60+
vis_layout.addLayout(speed_layout)
61+
62+
vis_group.setLayout(vis_layout)
63+
layout.addWidget(vis_group)
64+
65+
layout.addStretch()
66+
self.setLayout(layout)
67+
68+
def on_pos_changed(self):
69+
self.settings.set('Position', 'x', self.spin_x.value())
70+
self.settings.set('Position', 'y', self.spin_y.value())
71+
self.settings.save()
72+
73+
def on_visual_changed(self):
74+
self.settings.set('Visual', 'fall_direction', self.combo_dir.currentText())
75+
self.settings.set('Visual', 'scroll_speed', self.spin_speed.value())
76+
self.settings.save()

core/overlay.py

Lines changed: 93 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,11 @@ def spawn(self, lane_index, timestamp):
4747
if self.inactive_bars:
4848
bar = self.inactive_bars.pop()
4949
else:
50-
# Pool exhausted.
51-
# Strategy: If below max_size, create new.
52-
# If at max_size, recycle the oldest active bar (soft limit).
53-
total_count = len(self.active_bars) + len(self.inactive_bars) # logic check
54-
# Actually total known count is just what we have tracked.
55-
# Let's track total instantiated count if strictly needed,
56-
# but deque length is good enough.
57-
58-
# Simple count check:
5950
if (len(self.active_bars) + len(self.inactive_bars)) < self.max_size:
6051
bar = Bar()
6152
elif self.active_bars:
62-
# Soft Limit hit: Recycle oldest
63-
bar = self.active_bars.popleft() # Oldest is usually at the left/start
64-
# Technically we are taking it out of active to re-add it as new active
53+
bar = self.active_bars.popleft()
6554
else:
66-
# Should effectively never happen unless max_size=0
6755
bar = Bar()
6856

6957
bar.active = True
@@ -79,16 +67,21 @@ def recycle(self, bar):
7967
self.inactive_bars.append(bar)
8068

8169
class RainingKeysOverlay(QWidget):
82-
def __init__(self):
70+
def __init__(self, settings_manager):
8371
super().__init__()
84-
self.init_ui()
72+
self.settings = settings_manager
73+
# Connect to settings changed signal
74+
self.settings.settings_changed.connect(self.on_settings_changed)
75+
8576
self.pool = BarPool(Config.MAX_BARS)
8677
self.active_holds = {} # {lane_index: Bar} tracking currently held notes
8778

79+
self.init_ui()
80+
8881
# High-res timer for rendering
8982
self.timer = QTimer(self)
9083
self.timer.timeout.connect(self.update_canvas)
91-
self.timer.start(16) # ~60 FPS target trigger, but delta used for physics
84+
self.timer.start(16) # ~60 FPS target trigger
9285

9386
# Debug stats
9487
self.last_fps_time = time.perf_counter()
@@ -100,26 +93,42 @@ def init_ui(self):
10093
self.setWindowFlags(
10194
Qt.FramelessWindowHint |
10295
Qt.WindowStaysOnTopHint |
103-
Qt.Tool | # Tool prevents showing in alt-tab usually
104-
Qt.WindowTransparentForInput # Qt 5.10+ helper, acts as partial clickthrough
96+
Qt.Tool |
97+
Qt.WindowTransparentForInput
10598
)
10699
self.setAttribute(Qt.WA_TranslucentBackground)
107100
self.setAttribute(Qt.WA_TransparentForMouseEvents)
108101

109-
# Full screen
110-
screen_geo = QApplication.primaryScreen().geometry()
111-
self.setGeometry(screen_geo)
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()
111+
112+
self.resize(width, height)
113+
# Move to configured position
114+
self.move(self.settings.overlay_x, self.settings.overlay_y)
112115

113-
# Win32 Click-through + Always on Top enforcement
116+
# Win32 Click-through
114117
if HAS_WIN32:
115118
hwnd = int(self.winId())
116-
styles = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
117-
win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, styles | win32con.WS_EX_LAYERED | win32con.WS_EX_TRANSPARENT)
119+
try:
120+
styles = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
121+
win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, styles | win32con.WS_EX_LAYERED | win32con.WS_EX_TRANSPARENT)
122+
except Exception as e:
123+
print(f"Win32 Error: {e}")
124+
125+
def on_settings_changed(self):
126+
# Move window live when settings change
127+
self.move(self.settings.overlay_x, self.settings.overlay_y)
118128

119129
def handle_input(self, lane_index, timestamp):
120130
"""Slot called when input monitor detects a key press."""
121131
if lane_index in self.active_holds:
122-
# Key already held? (Should be filtered by input_mon, but safety check)
123132
pass
124133
else:
125134
bar = self.pool.spawn(lane_index, timestamp)
@@ -138,66 +147,88 @@ def paintEvent(self, event):
138147
painter = QPainter()
139148
try:
140149
if not painter.begin(self):
141-
# Failed to start painting (device occupied?)
142150
return
143151

144152
painter.setRenderHint(QPainter.Antialiasing)
145153

146154
current_time = time.perf_counter()
147-
148-
# Calculate Logic
149155
screen_h = self.height()
150156
to_recycle = []
151157

158+
# Get current settings
159+
speed = self.settings.scroll_speed
160+
falling_down = (self.settings.fall_direction == 'down')
161+
152162
# Bar Drawing Loop
153163
for bar in self.pool.active_bars:
154-
# Calculate Y positions
155-
# Bottom of the note (Leading Edge)
164+
# 1. Physics: Distance from Origin (Press Time)
156165
delta_press = current_time - bar.press_time + Config.INPUT_LATENCY_OFFSET
157-
y_bottom = delta_press * Config.SCROLL_SPEED
166+
dist_head = delta_press * speed
158167

159-
# Top of the note (Trailing Edge)
160168
if bar.release_time is None:
161-
# Still held: Top is at current time (0 offset from 'now')
162-
delta_release = Config.INPUT_LATENCY_OFFSET # effectively 0 time passed since 'now'
163-
y_top = delta_release * Config.SCROLL_SPEED
169+
# Held
170+
dist_tail = Config.INPUT_LATENCY_OFFSET * speed
164171
else:
172+
# Released
165173
delta_release = current_time - bar.release_time + Config.INPUT_LATENCY_OFFSET
166-
y_top = delta_release * Config.SCROLL_SPEED
174+
dist_tail = delta_release * speed
167175

168-
# Check bounds (Recycle if Top is off-screen)
169-
if y_top > screen_h:
170-
to_recycle.append(bar)
171-
continue
176+
# 2. Geometry
177+
height_bar = dist_head - dist_tail
178+
# Clamp min height so short taps are visible
179+
if height_bar < Config.BAR_HEIGHT:
180+
height_bar = Config.BAR_HEIGHT
181+
dist_tail = dist_head - height_bar
172182

173-
# Render only if VISIBLE
174-
if y_bottom < 0:
175-
continue
176-
177-
# Fade Logic (based on leading edge / y_bottom)
183+
# Calculate Screen Y
184+
if falling_down:
185+
# Spawn at 0 (Top)
186+
# Tail is "above" Head (smaller Y value)
187+
rect_y = dist_tail
188+
else:
189+
# Spawn at ScreenHeight (Bottom)
190+
# Head moves UP (smaller Y value)
191+
# Tail moves UP (larger Y value than Head)
192+
# Y = H - dist.
193+
# Head Y = H - dist_head.
194+
# Tail Y = H - dist_tail.
195+
# Rect Top = Head Y (Smallest Y)
196+
rect_y = screen_h - dist_head
197+
198+
# 3. Recycle Check
199+
if falling_down:
200+
if rect_y > screen_h:
201+
to_recycle.append(bar)
202+
continue
203+
else:
204+
# Moving Up. If the bottom of that rect (rect_y + height) is < 0, it is gone.
205+
if (rect_y + height_bar) < 0:
206+
to_recycle.append(bar)
207+
continue
208+
209+
# 4. Fade Logic (Distance Traveled based)
210+
# Use dist_head (leading edge travel distance)
178211
alpha = 1.0
179-
if y_bottom > Config.FADE_START_Y:
180-
dist_into_fade = y_bottom - Config.FADE_START_Y
212+
if dist_head > Config.FADE_START_Y:
213+
dist_into_fade = dist_head - Config.FADE_START_Y
181214
factor = 1.0 - (dist_into_fade / Config.FADE_RANGE)
182215
alpha = max(0.0, min(1.0, factor))
183-
184-
# Draw
216+
217+
# 5. Draw
218+
# Optimization: Don't draw if transparent
219+
if alpha <= 0.0:
220+
continue
221+
185222
x = Config.LANE_START_X + (bar.lane_index * Config.LANE_WIDTH)
186223

187-
# Height
188-
h = max(Config.BAR_HEIGHT, y_bottom - y_top)
189-
190-
draw_y = y_bottom - h
191-
192-
# Apply Color
193224
c = QColor(Config.COLOR_BAR)
194225
c.setAlphaF(alpha * (Config.COLOR_BAR.alphaF()))
195226

196227
painter.setBrush(QBrush(c))
197228
painter.setPen(Qt.NoPen)
198-
painter.drawRect(QRectF(x, draw_y, Config.BAR_WIDTH, h))
229+
painter.drawRect(QRectF(x, rect_y, Config.BAR_WIDTH, height_bar))
199230

200-
# Recycle off-screen bars
231+
# Recycle
201232
for bar in to_recycle:
202233
try:
203234
self.pool.active_bars.remove(bar)
@@ -230,9 +261,11 @@ def draw_debug(self, painter, current_time):
230261

231262
info = [
232263
f"FPS: {self.current_fps:.1f}",
233-
f"Active Bars: {active_count} / {Config.MAX_BARS}",
234-
f"Pool Size: {pool_size}",
235-
f"Speed: {Config.SCROLL_SPEED} px/s"
264+
f"Active: {active_count}",
265+
f"Pool: {pool_size}",
266+
f"Speed: {self.settings.scroll_speed}",
267+
f"Dir: {self.settings.fall_direction}",
268+
f"Pos: {self.x()},{self.y()}"
236269
]
237270

238271
for i, line in enumerate(info):

0 commit comments

Comments
 (0)