|
| 1 | +# Click Rate Redesign — Design Spec |
| 2 | + |
| 3 | +**Date:** 2026-04-25 |
| 4 | +**Scope:** Windows app (`windows/`). macOS and Linux tracks unchanged. |
| 5 | +**Status:** Approved for implementation. |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +The current Click Rate input mixes two paradigms (delay-per-click vs. clicks-per-time-unit) into a single value+unit row: |
| 10 | + |
| 11 | +``` |
| 12 | +Click Rate: [100] [ms ▾] ← unit dropdown contains: ms, /s, /min |
| 13 | +``` |
| 14 | + |
| 15 | +Users have to mentally translate: "I want 10 clicks/sec" → "that's 100 ms" — or rely on the parser to accept either. The current ms default also implies a frame-of-reference (delay) that not all users share. The dropdown silently mixes `ms` (a duration) with `/s` and `/min` (rates), which is the same kind of category error as putting "miles" and "miles per hour" in the same dropdown. |
| 16 | + |
| 17 | +## Goal |
| 18 | + |
| 19 | +Split the control into a **Mode** (Delay vs. Frequency) chosen by radio button, and surface only the units valid for that mode. Show a live conversion hint so users build intuition without having to think in two paradigms at once. Add a "very fast" warning before users land on rates that misbehave on most apps. |
| 20 | + |
| 21 | +## Non-goals |
| 22 | + |
| 23 | +- Not redesigning any other field (mouse button, click type, location, stop conditions, hotkeys). |
| 24 | +- Not changing macOS or Linux apps. |
| 25 | +- Not changing the click engine or input injection. |
| 26 | +- Not breaking existing CLI scripts. |
| 27 | + |
| 28 | +## Design |
| 29 | + |
| 30 | +### 1. UX / Layout (`MainWindow.xaml`) |
| 31 | + |
| 32 | +Replace the single Click Rate row with a two-row block: |
| 33 | + |
| 34 | +``` |
| 35 | +Click Rate: ◉ Delay ○ Frequency |
| 36 | + [ 100 ] [ ms ▾ ] |
| 37 | + ≈ 10 clicks/sec |
| 38 | +``` |
| 39 | + |
| 40 | +Toggling to **Frequency**: |
| 41 | +``` |
| 42 | +Click Rate: ○ Delay ◉ Frequency |
| 43 | + [ 10 ] [ per sec ▾ ] |
| 44 | + ≈ 100 ms between clicks |
| 45 | +``` |
| 46 | + |
| 47 | +**Behavior** |
| 48 | + |
| 49 | +- Mode radios live on the same row as the `Click Rate:` label, on the right, in a horizontal `StackPanel`. |
| 50 | +- The value `TextBox` is fixed-width (~80px), the unit `ComboBox` (~110px) follows. |
| 51 | +- Toggling Mode swaps the dropdown's items and re-validates the current value against the new mode's bounds. If out of range for the new mode, the value is clamped (silent — not an error). |
| 52 | +- The hint line below uses `TextSecondary` color, 11px, single line. |
| 53 | +- When the effective rate is **> 100 clicks/second** (delay < 10ms), the hint switches to `Danger` color and shows `⚠ Very fast — input may not register reliably` followed by the conversion (`≈ 50 ms between clicks`) on the same wrapped block. |
| 54 | +- Numeric value box: positive numbers only. Decimals allowed for `sec`, `min`, `per min`, `per hour`. Integer-only enforced for `ms` and `per sec`. |
| 55 | +- Empty / zero / out-of-range hard-rejects only on Start (existing inline `ErrorLabel`) — the live hint just gives feedback. |
| 56 | +- Window height grows ~30px (one extra row). Stays within `MinHeight=430`. |
| 57 | + |
| 58 | +**Units exposed** |
| 59 | + |
| 60 | +| Mode | Dropdown items (Tag values) | Bounds | |
| 61 | +|---|---|---| |
| 62 | +| Delay | `ms`, `sec`, `min` | 1 ms – 360 min | |
| 63 | +| Frequency | `per sec`, `per min`, `per hour` | 1 / hour – 1000 / sec | |
| 64 | + |
| 65 | +Hard floor is **1 ms** (= 1000/sec). Hard ceiling for delay is **360 min** (= 6 hours). These bounds are enforced in the parser, not just the UI. |
| 66 | + |
| 67 | +### 2. Parser (`Core/ClickRateParser.cs`) |
| 68 | + |
| 69 | +**Additive only** — every existing format keeps working. New unit tokens: |
| 70 | + |
| 71 | +| New token forms | Parses to | |
| 72 | +|---|---| |
| 73 | +| `s`, `sec`, `seconds`, `secs` (suffix) | seconds → ms | |
| 74 | +| `m`, `min`, `minutes`, `mins` (suffix) | minutes → ms | |
| 75 | +| `/h`, `cph`, `times per hour` | per hour → ms delay | |
| 76 | + |
| 77 | +**Disambiguation**: `m` alone is ambiguous (minutes vs. milliseconds-prefix-typo) — accept it as **minutes**, since `ms` is parsed first. Order of suffix checks: `ms` → `min`/`m` → `sec`/`s` → `/h`/`cph` → `/min`/`cpm` → `/s`/`cps` → bare integer. |
| 78 | + |
| 79 | +**Bounds enforcement** (all paths, not just new ones): |
| 80 | + |
| 81 | +``` |
| 82 | +const long MaxDelayMs = 360L * 60_000; // 360 minutes |
| 83 | +const double MinDelayMs = 1.0; |
| 84 | +``` |
| 85 | + |
| 86 | +If parsed delay falls outside `[MinDelayMs, MaxDelayMs]`, return `false` with a clear error message. |
| 87 | + |
| 88 | +**Public surface unchanged**: |
| 89 | +```csharp |
| 90 | +public static bool TryParse(string text, out TimeSpan delay, out string error) |
| 91 | +``` |
| 92 | + |
| 93 | +CLI consumers and existing tests continue to work. |
| 94 | + |
| 95 | +### 3. Settings (`Models/AppSettings.cs`) |
| 96 | + |
| 97 | +**New schema**: |
| 98 | +```csharp |
| 99 | +public ClickRateMode ClickRateMode { get; set; } = ClickRateMode.Delay; |
| 100 | +public string ClickRateValue { get; set; } = "100"; |
| 101 | +public string ClickRateUnit { get; set; } = "ms"; // canonical Tag value |
| 102 | +``` |
| 103 | + |
| 104 | +**New enum** in `Models/ClickRateMode.cs`: |
| 105 | +```csharp |
| 106 | +public enum ClickRateMode { Delay, Frequency } |
| 107 | +``` |
| 108 | + |
| 109 | +**Canonical unit tags** (stored as strings for forward-compat, enum'd at runtime): |
| 110 | +- Delay: `"ms"`, `"sec"`, `"min"` |
| 111 | +- Frequency: `"per_sec"`, `"per_min"`, `"per_hour"` |
| 112 | + |
| 113 | +**Migration** (handled in `AppSettings.Load()`): |
| 114 | +- After deserialization, if `ClickRateMode` is missing/default AND the old `ClickRateUnit` is one of the legacy values: |
| 115 | + - `"ms"` → Mode=Delay, Unit=`"ms"` |
| 116 | + - `"/s"` → Mode=Frequency, Unit=`"per_sec"` |
| 117 | + - `"/min"` → Mode=Frequency, Unit=`"per_min"` |
| 118 | +- Migration runs once; the next `Save()` writes the new schema and the legacy unit string is overwritten. |
| 119 | +- Detection: a `[JsonIgnore]` flag isn't needed; we detect by checking whether the deserialized `ClickRateUnit` is one of the legacy values that no longer match the new canonical set. |
| 120 | + |
| 121 | +### 4. Code-behind (`MainWindow.xaml.cs`) |
| 122 | + |
| 123 | +New private state: none (all state lives on the controls). |
| 124 | + |
| 125 | +**New handlers**: |
| 126 | +- `ClickRateMode_Changed` — fires when either Mode radio toggles. Repopulates `ClickRateUnitBox` items, restores selection from the matching settings unit (or the first item), and recomputes the hint. |
| 127 | +- `ClickRateValueBox_TextChanged` and `ClickRateUnitBox_SelectionChanged` — recompute the hint by calling the existing `ClickRateParser.TryParse` against the composed string and rendering a counterpart-side view. |
| 128 | + |
| 129 | +**Hint rendering helper** (private static): |
| 130 | +```csharp |
| 131 | +private void UpdateRateHint() |
| 132 | +{ |
| 133 | + if (!ClickRateParser.TryParse(ComposedRateText, out var delay, out _)) |
| 134 | + { |
| 135 | + RateHintLabel.Text = string.Empty; |
| 136 | + return; |
| 137 | + } |
| 138 | + double cps = 1000.0 / delay.TotalMilliseconds; |
| 139 | + bool isDelayMode = ModeDelay.IsChecked == true; |
| 140 | + string conversion = isDelayMode ? FormatRate(cps) : FormatDelay(delay); |
| 141 | + bool veryFast = cps > 100; |
| 142 | + RateHintLabel.Foreground = veryFast ? DangerBrush : TextSecondaryBrush; |
| 143 | + RateHintLabel.Text = veryFast |
| 144 | + ? $"⚠ Very fast — input may not register reliably (≈ {conversion})" |
| 145 | + : $"≈ {conversion}"; |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | +Existing `TryBuildSession` updates: composes `value + unit` differently per mode (e.g., `"10per_sec"` → translate `per_sec` → `/s` for parser input, since the parser still speaks the old DSL externally). Or simpler: have a small `ComposeRateString(mode, value, unitTag)` helper that maps internal tag → parser-compatible suffix. |
| 150 | + |
| 151 | +**Persistence**: `SaveSettings()` records `ClickRateMode`, the numeric `ClickRateValue`, and the canonical unit tag. |
| 152 | + |
| 153 | +### 5. Tests |
| 154 | + |
| 155 | +Add to `Tests/ClickRateParserTests.cs`: |
| 156 | +- `Seconds_ValidInput` — `"5s"`, `"5 sec"`, `"5seconds"` → 5000ms. |
| 157 | +- `Minutes_ValidInput` — `"2min"`, `"2 minutes"`, `"2m"` → 120000ms. |
| 158 | +- `PerHour_ValidInput` — `"60/h"`, `"60cph"`, `"60 times per hour"` → 60000ms. |
| 159 | +- `MaxDelay_AcceptedAtBoundary` — `"360min"` parses; `"361min"` rejects. |
| 160 | +- `MaxRate_AcceptedAtBoundary` — `"1000/s"` parses (= 1ms); `"1001/s"` rejects. |
| 161 | +- `MinDelay_RejectsBelowOne` — `"0.5ms"` rejects. |
| 162 | + |
| 163 | +New `Tests/AppSettingsMigrationTests.cs`: |
| 164 | +- Old JSON with `"ClickRateUnit": "/s"` and no `ClickRateMode` field → loads as Frequency / per_sec. |
| 165 | +- Old JSON with `"/min"` → Frequency / per_min. |
| 166 | +- Old JSON with `"ms"` → Delay / ms. |
| 167 | +- New JSON round-trips unchanged. |
| 168 | + |
| 169 | +## Out-of-scope risks accepted |
| 170 | + |
| 171 | +- **Multi-monitor / DPI**: untouched. |
| 172 | +- **Localization**: hint text is English-only (matches the rest of the app). |
| 173 | +- **Accessibility**: radio buttons + textbox + combobox are all natively keyboard-accessible. No new accessibility work needed. |
| 174 | + |
| 175 | +## Rollback |
| 176 | + |
| 177 | +Single-revert friendly. Settings migration is forward-only; users who downgrade get default Delay/100/ms — minor inconvenience, no corruption. |
0 commit comments