Skip to content

Commit ed456c4

Browse files
authored
feat(grid): allow bounded coarse selection labels (#1537)
1 parent e366ca3 commit ed456c4

23 files changed

Lines changed: 667 additions & 198 deletions

configs/default-config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ generic_clickable_min_confidence = 0.5 # Minimum confidence for generic cl
182182
enabled = true
183183
characters = "abcdefghijklmnpqrstuvwxyz" # Primary grid labels
184184
sublayer_keys = "abcdefghijklmnpqrstuvwxyz" # Subgrid labels, first 9 used (empty = infer from characters)
185+
max_label_length = 4 # Maximum coarse label length (2–4); 4 keeps the legacy automatic layout
185186
row_labels = "" # Custom row labels (empty = infer from characters)
186187
col_labels = "" # Custom column labels (empty = infer from characters)
187188
live_match_update = true # Highlight cells as you type

configs/grid-only-config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ enabled = false
1010
[grid]
1111
enabled = true
1212
characters = "abcdefghijklmnpqrstuvwxyz"
13+
max_label_length = 4
1314
prewarm_enabled = true
1415
enable_gc = false
1516

docs/CONFIGURATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,7 @@ Cursor behavior is chosen per invocation: `neru grid --cursor-selection-mode fol
10551055
| `enabled` | bool | `true` | Enable/disable grid mode |
10561056
| `characters` | string | `"abcdefghijklmnpqrstuvwxyz"` | Primary grid labels |
10571057
| `sublayer_keys` | string | `"abcdefghijklmnpqrstuvwxyz"` | Subgrid labels; empty is resolved at load time to the characters the grid is labelled with, the same ones `row_labels` is inferred from. Only the first 9 are used — the subgrid is 3×3 |
1058+
| `max_label_length` | int | `4` | Maximum coarse-grid label length (2–4). The default preserves the legacy automatic 2–4-key layout. When a limit of 2 shortens an automatically longer label, the coarse grid is enlarged and spatially rebalanced while still covering the screen; the following subgrid refinement remains one keypress |
10581059
| `row_labels` | string | `""` | Custom row labels; empty is resolved at load time to the labels inferred from `characters` |
10591060
| `col_labels` | string | `""` | Custom column labels; empty is resolved the same way as `row_labels` |
10601061
| `live_match_update` | bool | `true` | Highlight cells as you type |

internal/adapter/overlay/render/grid/overlay_darwin.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,24 @@ func getCommonGridSizes() []image.Rectangle {
138138
}
139139
}
140140

141+
func prewarm(config config.GridConfig) {
142+
if !config.PrewarmEnabled {
143+
return
144+
}
145+
146+
options := domainGrid.Options{
147+
Characters: config.Characters,
148+
RowLabels: config.RowLabels,
149+
ColLabels: config.ColLabels,
150+
MaxLabelLength: config.MaxLabelLength,
151+
}
152+
go func() {
153+
for _, rect := range getCommonGridSizes() {
154+
_ = domainGrid.NewGridWithOptions(options, rect, zap.NewNop())
155+
}
156+
}()
157+
}
158+
141159
// NewOverlay creates a new grid overlay instance with its own window and prewarms common grid sizes.
142160
func NewOverlay(config config.GridConfig, logger *zap.Logger) (*Overlay, error) {
143161
base, err := overlayutil.NewBaseOverlay(logger)
@@ -146,11 +164,7 @@ func NewOverlay(config config.GridConfig, logger *zap.Logger) (*Overlay, error)
146164
}
147165
base.CallbackManager.SetComponent("grid")
148166
initGridPools()
149-
chars := config.Characters
150-
151-
if config.PrewarmEnabled {
152-
go domainGrid.Prewarm(chars, getCommonGridSizes())
153-
}
167+
prewarm(config)
154168

155169
return &Overlay{
156170
window: C.OverlayWindow(base.Window),
@@ -169,11 +183,7 @@ func NewOverlayWithWindow(
169183
windowPtr unsafe.Pointer,
170184
) *Overlay {
171185
initGridPools()
172-
chars := config.Characters
173-
174-
if config.PrewarmEnabled {
175-
go domainGrid.Prewarm(chars, getCommonGridSizes())
176-
}
186+
prewarm(config)
177187
base := overlayutil.NewBaseOverlayWithWindow(logger, windowPtr)
178188
base.CallbackManager.SetComponent("grid")
179189

internal/app/components/types.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,17 @@ func (g *GridComponent) UpdateConfig(cfg *config.Config, logger *zap.Logger) {
6060
charactersChanged := newCharacters != oldGrid.Characters()
6161
rowLabelsChanged := newRowLabels != oldGrid.RowLabels()
6262
colLabelsChanged := newColLabels != oldGrid.ColLabels()
63+
maxLabelLengthChanged := cfg.Grid.MaxLabelLength != oldGrid.MaxLabelLength()
6364

64-
if charactersChanged || rowLabelsChanged || colLabelsChanged {
65+
if charactersChanged || rowLabelsChanged || colLabelsChanged ||
66+
maxLabelLengthChanged {
6567
logger.Debug("Recreating grid due to config changes",
6668
zap.Bool("charactersChanged", charactersChanged),
6769
zap.Bool("rowLabelsChanged", rowLabelsChanged),
68-
zap.Bool("colLabelsChanged", colLabelsChanged))
69-
newGrid := domainGrid.NewGridWithLabels(
70-
characters,
71-
cfg.Grid.RowLabels,
72-
cfg.Grid.ColLabels,
70+
zap.Bool("colLabelsChanged", colLabelsChanged),
71+
zap.Bool("maxLabelLengthChanged", maxLabelLengthChanged))
72+
newGrid := domainGrid.NewGridWithOptions(
73+
cfg.GridOptions(),
7374
oldGrid.Bounds(),
7475
logger,
7576
)

internal/app/components/types_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,33 @@ func TestGridComponent_UpdateConfig_RecreatedGridKeepsBounds(t *testing.T) {
217217
}
218218
}
219219

220+
// TestGridComponent_UpdateConfig_RecreatesGridOnMaxLabelLengthChange pins hot
221+
// reload for the geometry option: the manager must start using the new coarse
222+
// label bound immediately, and an unchanged follow-up reload must keep it.
223+
func TestGridComponent_UpdateConfig_RecreatesGridOnMaxLabelLengthChange(t *testing.T) {
224+
component := newGridComponent(t, testCharacters, "", "")
225+
before := component.Manager.Grid()
226+
227+
cfg := gridConfig(testCharacters, "", "")
228+
cfg.Grid.MaxLabelLength = 2
229+
component.UpdateConfig(cfg, zap.NewNop())
230+
231+
after := component.Manager.Grid()
232+
if after == before {
233+
t.Fatal("changing max_label_length did not recreate the grid")
234+
}
235+
236+
if got := after.MaxLabelLength(); got != 2 {
237+
t.Errorf("MaxLabelLength() = %d, want 2", got)
238+
}
239+
240+
component.UpdateConfig(cfg, zap.NewNop())
241+
242+
if component.Manager.Grid() != after {
243+
t.Error("reloading an unchanged max_label_length rebuilt the grid")
244+
}
245+
}
246+
220247
// TestGridComponent_UpdateConfig_DisabledGridIsLeftAlone makes sure a disabled
221248
// grid is not silently rebuilt behind the user's back.
222249
func TestGridComponent_UpdateConfig_DisabledGridIsLeftAlone(t *testing.T) {

internal/app/modes/grid.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,8 @@ func (h *handlerState) createGridInstance() *domainGrid.Grid {
119119
// Normalize normalizedBounds to window-local coordinates using helper function
120120
normalizedBounds := geometry.NormalizeToLocalCoordinates(screenBounds)
121121

122-
gridInstance := domainGrid.NewGridWithLabels(
123-
h.config.GridCharacters(),
124-
h.config.Grid.RowLabels,
125-
h.config.Grid.ColLabels,
122+
gridInstance := domainGrid.NewGridWithOptions(
123+
h.config.GridOptions(),
126124
normalizedBounds,
127125
h.logger,
128126
)
@@ -151,10 +149,8 @@ func (h *handlerState) initializeGridManager(gridInstance *domainGrid.Grid) {
151149
}
152150

153151
bounds := image.Rect(0, 0, screenBounds.Dx(), screenBounds.Dy())
154-
gridInstance = domainGrid.NewGridWithLabels(
155-
h.config.GridCharacters(),
156-
h.config.Grid.RowLabels,
157-
h.config.Grid.ColLabels,
152+
gridInstance = domainGrid.NewGridWithOptions(
153+
h.config.GridOptions(),
158154
bounds,
159155
h.logger,
160156
)

internal/app/modes/grid_labels_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,27 @@ func TestCreateGridInstance_UsesTheResolvedLabels(t *testing.T) {
9595
}
9696
}
9797

98+
// TestCreateGridInstance_UsesMaxLabelLength pins the mode-to-domain wiring for
99+
// the two-key coarse selection option.
100+
func TestCreateGridInstance_UsesMaxLabelLength(t *testing.T) {
101+
cfg := config.DefaultConfig()
102+
cfg.Grid.Enabled = true
103+
cfg.Grid.Characters = "abcd"
104+
cfg.Grid.MaxLabelLength = 2
105+
cfg.ResolveGridLabels()
106+
107+
gridInstance := newGridLabelHandler(cfg).createGridInstance()
108+
if got := gridInstance.MaxLabelLength(); got != 2 {
109+
t.Fatalf("grid MaxLabelLength() = %d, want 2", got)
110+
}
111+
112+
for _, cell := range gridInstance.Cells() {
113+
if got := len(cell.Coordinate()); got > 2 {
114+
t.Fatalf("coordinate %q has length %d, want at most 2", cell.Coordinate(), got)
115+
}
116+
}
117+
}
118+
98119
// TestInitializeGridManager_FallbackGridUsesTheResolvedLabels covers the
99120
// defensive branch that builds its own grid when handed none. It reached for
100121
// grid.characters directly while the path above reached for the hint

internal/app/modes/monitor.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,8 @@ func (h *handlerState) refreshGridForMonitorMove(targetBounds image.Rectangle) {
320320
h.setScreenBounds(targetBounds)
321321
normalizedBounds := geometry.NormalizeToLocalCoordinates(targetBounds)
322322

323-
gridInstance := domainGrid.NewGridWithLabels(
324-
h.config.GridCharacters(),
325-
h.config.Grid.RowLabels,
326-
h.config.Grid.ColLabels,
327-
normalizedBounds,
328-
h.logger,
323+
gridInstance := domainGrid.NewGridWithOptions(
324+
h.config.GridOptions(), normalizedBounds, h.logger,
329325
)
330326
h.grid.Context.SetGridInstanceValue(gridInstance)
331327

internal/app/simulation_journey_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,6 +1839,7 @@ const moveMonitorHotkey = "Primary+Shift+N"
18391839
// the calls in between.
18401840
func TestSimulation_MonitorMoveRedrawsTheModeOnTheNewDisplay(t *testing.T) {
18411841
cfg := simConfig()
1842+
cfg.Grid.MaxLabelLength = 2
18421843
cfg.Hotkeys.Bindings[moveMonitorHotkey] = []string{
18431844
"action move_monitor --name " + secondDisplayName,
18441845
}
@@ -1873,6 +1874,10 @@ func TestSimulation_MonitorMoveRedrawsTheModeOnTheNewDisplay(t *testing.T) {
18731874
if sim.app.CurrentMode() != domain.ModeGrid {
18741875
t.Fatalf("mode after monitor move = %v, want grid", sim.app.CurrentMode())
18751876
}
1877+
1878+
if got := sim.overlay.lastGrid().MaxLabelLength(); got != cfg.Grid.MaxLabelLength {
1879+
t.Errorf("grid label limit after monitor move = %d, want %d", got, cfg.Grid.MaxLabelLength)
1880+
}
18761881
}
18771882

18781883
// TestSimulation_ScreenChangeWhileIdleLeavesTheOverlayHidden pins the quietest

0 commit comments

Comments
 (0)