detached 454609d

feat(state): expose each tiled window's column and row (#140)

Closes #138
commit
454609dab85d23c760ad4df94dd34780c38f5e4f
author
bnema · · 2026-10-05 17:53 UTC
committer
GitHub · · 2026-10-05 17:53 UTC
parent
091596c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +66 -14
M docs/desktop.md +1 -0
@@ -107,6 +107,7 @@ While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland so
107107 - the focused output and window;
108108 - every window, with its app ID, PID, output, workspace, `workspace_id` (the ID string of its workspace, numbered or hidden) and whether it is on screen (`visible`);
109109 - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden.
110+- for tiled windows, their 1-based `column` (layout order, left to right with `overflow = "scroll"`; with `overflow = "fixed"`, spiral and expanded columns break that, so a later column can sit below or beside an earlier one) and `row` (top to bottom in the column); both are `0` when `floating` is true. Sort by `column`, `row` for layout order.
110111
111112 ```sh
112113 neferwl state # the whole state as JSON
@@ -107,6 +107,7 @@ While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland so
107 - the focused output and window;107 - the focused output and window;
108 - every window, with its app ID, PID, output, workspace, `workspace_id` (the ID string of its workspace, numbered or hidden) and whether it is on screen (`visible`);108 - every window, with its app ID, PID, output, workspace, `workspace_id` (the ID string of its workspace, numbered or hidden) and whether it is on screen (`visible`);
109 - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden.109 - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden.
110+- for tiled windows, their 1-based `column` (layout order, left to right with `overflow = "scroll"`; with `overflow = "fixed"`, spiral and expanded columns break that, so a later column can sit below or beside an earlier one) and `row` (top to bottom in the column); both are `0` when `floating` is true. Sort by `column`, `row` for layout order.
110 111
111 ```sh112 ```sh
112 neferwl state # the whole state as JSON113 neferwl state # the whole state as JSON
M internal/adapters/statefile/statefile.go +5 -1
@@ -69,6 +69,10 @@ type Window struct {
6969 // workspace's stash of StashCount windows; both are 0 outside it.
7070 StashIndex int `json:"stash_index"`
7171 StashCount int `json:"stash_count"`
72+ // Column and Row are the 1-based place of a tiled window; both are 0
73+ // for floating windows.
74+ Column int `json:"column"`
75+ Row int `json:"row"`
7276 // Hidden is set for a stashed window while the stash is hidden.
7377 Hidden bool `json:"hidden"`
7478 }
@@ -89,7 +93,7 @@ func fromPorts(st ports.State, ids *workspaceid.IDs) State {
8993 }
9094
9195 func window(w ports.WindowState, ids *workspaceid.IDs) Window {
92- return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Hidden: w.Hidden}
96+ return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
9397 }
9498
9599 // Run writes every state it receives to path until ctx ends, then removes
@@ -69,6 +69,10 @@ type Window struct {
69 // workspace's stash of StashCount windows; both are 0 outside it.69 // workspace's stash of StashCount windows; both are 0 outside it.
70 StashIndex int `json:"stash_index"`70 StashIndex int `json:"stash_index"`
71 StashCount int `json:"stash_count"`71 StashCount int `json:"stash_count"`
72+ // Column and Row are the 1-based place of a tiled window; both are 0
73+ // for floating windows.
74+ Column int `json:"column"`
75+ Row int `json:"row"`
72 // Hidden is set for a stashed window while the stash is hidden.76 // Hidden is set for a stashed window while the stash is hidden.
73 Hidden bool `json:"hidden"`77 Hidden bool `json:"hidden"`
74 }78 }
@@ -89,7 +93,7 @@ func fromPorts(st ports.State, ids *workspaceid.IDs) State {
89 }93 }
90 94
91 func window(w ports.WindowState, ids *workspaceid.IDs) Window {95 func window(w ports.WindowState, ids *workspaceid.IDs) Window {
92- return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Hidden: w.Hidden}96+ return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
93 }97 }
94 98
95 // Run writes every state it receives to path until ctx ends, then removes99 // Run writes every state it receives to path until ctx ends, then removes
M internal/adapters/statefile/statefile_test.go +5 -5
@@ -64,17 +64,17 @@ func TestRunWritesAndRemoves(t *testing.T) {
6464 }
6565 }
6666
67-// Stash fields reach the file with their script-facing names.
68-func TestWindowStashFields(t *testing.T) {
69- got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Hidden: true}, workspaceid.WithPrefix("0a1b2c3d"))
70- if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || !got.Hidden {
67+// Stash and cell fields reach the file with their script-facing names.
68+func TestWindowScriptFields(t *testing.T) {
69+ got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Column: 5, Row: 6, Hidden: true}, workspaceid.WithPrefix("0a1b2c3d"))
70+ if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || got.Column != 5 || got.Row != 6 || !got.Hidden {
7171 t.Fatalf("%+v", got)
7272 }
7373 data, err := json.Marshal(got)
7474 if err != nil {
7575 t.Fatal(err)
7676 }
77- for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"hidden":true`} {
77+ for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"column":5`, `"row":6`, `"hidden":true`} {
7878 if !strings.Contains(string(data), key) {
7979 t.Fatalf("%s missing %s", data, key)
8080 }
@@ -64,17 +64,17 @@ func TestRunWritesAndRemoves(t *testing.T) {
64 }64 }
65 }65 }
66 66
67-// Stash fields reach the file with their script-facing names.67+// Stash and cell fields reach the file with their script-facing names.
68-func TestWindowStashFields(t *testing.T) {68+func TestWindowScriptFields(t *testing.T) {
69- got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Hidden: true}, workspaceid.WithPrefix("0a1b2c3d"))69+ got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Column: 5, Row: 6, Hidden: true}, workspaceid.WithPrefix("0a1b2c3d"))
70- if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || !got.Hidden {70+ if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || got.Column != 5 || got.Row != 6 || !got.Hidden {
71 t.Fatalf("%+v", got)71 t.Fatalf("%+v", got)
72 }72 }
73 data, err := json.Marshal(got)73 data, err := json.Marshal(got)
74 if err != nil {74 if err != nil {
75 t.Fatal(err)75 t.Fatal(err)
76 }76 }
77- for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"hidden":true`} {77+ for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"column":5`, `"row":6`, `"hidden":true`} {
78 if !strings.Contains(string(data), key) {78 if !strings.Contains(string(data), key) {
79 t.Fatalf("%s missing %s", data, key)79 t.Fatalf("%s missing %s", data, key)
80 }80 }
M internal/core/layout.go +10 -0
@@ -307,6 +307,16 @@ func (w *Workspace) windows() []WindowID {
307307 return ids
308308 }
309309
310+// cell is the 1-based column and row of a tiled window, 0, 0 otherwise.
311+func (w *Workspace) cell(id WindowID) (col, row int) {
312+ for ci, c := range w.Columns {
313+ if ri := slices.Index(c.Windows, id); ri >= 0 {
314+ return ci + 1, ri + 1
315+ }
316+ }
317+ return 0, 0
318+}
319+
310320 func (w *Workspace) floatIndex(id WindowID) int {
311321 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
312322 }
@@ -307,6 +307,16 @@ func (w *Workspace) windows() []WindowID {
307 return ids307 return ids
308 }308 }
309 309
310+// cell is the 1-based column and row of a tiled window, 0, 0 otherwise.
311+func (w *Workspace) cell(id WindowID) (col, row int) {
312+ for ci, c := range w.Columns {
313+ if ri := slices.Index(c.Windows, id); ri >= 0 {
314+ return ci + 1, ri + 1
315+ }
316+ }
317+ return 0, 0
318+}
319+
310 func (w *Workspace) floatIndex(id WindowID) int {320 func (w *Workspace) floatIndex(id WindowID) int {
311 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })321 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
312 }322 }
M internal/core/overview_stack.go +2 -6
@@ -32,12 +32,8 @@ func (w *Workspace) overviewMaximized() bool {
3232 }
3333
3434 func (w *Workspace) columnOf(id WindowID) int {
35- for i, c := range w.Columns {
36- if slices.Contains(c.Windows, id) {
37- return i
38- }
39- }
40- return -1
35+ c, _ := w.cell(id)
36+ return c - 1
4137 }
4238
4339 // stack lists what is on screen followed by what it hides, top to bottom.
@@ -32,12 +32,8 @@ func (w *Workspace) overviewMaximized() bool {
32 }32 }
33 33
34 func (w *Workspace) columnOf(id WindowID) int {34 func (w *Workspace) columnOf(id WindowID) int {
35- for i, c := range w.Columns {35+ c, _ := w.cell(id)
36- if slices.Contains(c.Windows, id) {36+ return c - 1
37- return i
38- }
39- }
40- return -1
41 }37 }
42 38
43 // stack lists what is on screen followed by what it hides, top to bottom.39 // stack lists what is on screen followed by what it hides, top to bottom.
M internal/core/screens_test.go +2 -2
@@ -799,8 +799,8 @@ func TestStateSnapshot(t *testing.T) {
799799 Output: "DP-2",
800800 Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
801801 Windows: []ports.WindowState{
802- {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
803- {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
802+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true, Column: 1, Row: 1},
803+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},
804804 },
805805 }
806806 want.Window = &want.Windows[1]
@@ -799,8 +799,8 @@ func TestStateSnapshot(t *testing.T) {
799 Output: "DP-2",799 Output: "DP-2",
800 Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},800 Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
801 Windows: []ports.WindowState{801 Windows: []ports.WindowState{
802- {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},802+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true, Column: 1, Row: 1},
803- {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},803+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},
804 },804 },
805 }805 }
806 want.Window = &want.Windows[1]806 want.Window = &want.Windows[1]
M internal/core/state.go +3 -0
@@ -52,6 +52,9 @@ func (c *Core) state() ports.State {
5252 if i := w.stashIndex(id); i >= 0 {
5353 v.StashIndex, v.StashCount, v.Hidden = i+1, len(w.Stash), w.stashHidden
5454 }
55+ if !v.Floating {
56+ v.Column, v.Row = w.cell(id)
57+ }
5558 st.Windows = append(st.Windows, v)
5659 }
5760 }
@@ -52,6 +52,9 @@ func (c *Core) state() ports.State {
52 if i := w.stashIndex(id); i >= 0 {52 if i := w.stashIndex(id); i >= 0 {
53 v.StashIndex, v.StashCount, v.Hidden = i+1, len(w.Stash), w.stashHidden53 v.StashIndex, v.StashCount, v.Hidden = i+1, len(w.Stash), w.stashHidden
54 }54 }
55+ if !v.Floating {
56+ v.Column, v.Row = w.cell(id)
57+ }
55 st.Windows = append(st.Windows, v)58 st.Windows = append(st.Windows, v)
56 }59 }
57 }60 }
A internal/core/state_cell_test.go +34 -0
@@ -0,0 +1,34 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Tiled windows report their column and row; stashed windows and native
10+// floats report 0, 0.
11+func TestStateColumnRow(t *testing.T) {
12+ cfg := ports.Config{}
13+ cfg.Keyboard.CmdKey = "super"
14+ cfg.Layout.MaxColumns = 1
15+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)})
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 400, Height: 300})
20+ w := c.cur().mon.Current()
21+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{65, 66}}, {Windows: []WindowID{2}}}
22+ w.Stash = []Float{{ID: 7}}
23+ w.Floats = []Float{{ID: 8}}
24+ want := map[WindowID][2]int{1: {1, 1}, 65: {2, 1}, 66: {2, 2}, 2: {3, 1}, 7: {0, 0}, 8: {0, 0}}
25+ got := map[WindowID][2]int{}
26+ for _, s := range c.state().Windows {
27+ got[s.ID] = [2]int{s.Column, s.Row}
28+ }
29+ for id, cr := range want {
30+ if g, ok := got[id]; !ok || g != cr {
31+ t.Errorf("window %d: column,row %v, want %v", id, g, cr)
32+ }
33+ }
34+}
@@ -0,0 +1,34 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Tiled windows report their column and row; stashed windows and native
10+// floats report 0, 0.
11+func TestStateColumnRow(t *testing.T) {
12+ cfg := ports.Config{}
13+ cfg.Keyboard.CmdKey = "super"
14+ cfg.Layout.MaxColumns = 1
15+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)})
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 400, Height: 300})
20+ w := c.cur().mon.Current()
21+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{65, 66}}, {Windows: []WindowID{2}}}
22+ w.Stash = []Float{{ID: 7}}
23+ w.Floats = []Float{{ID: 8}}
24+ want := map[WindowID][2]int{1: {1, 1}, 65: {2, 1}, 66: {2, 2}, 2: {3, 1}, 7: {0, 0}, 8: {0, 0}}
25+ got := map[WindowID][2]int{}
26+ for _, s := range c.state().Windows {
27+ got[s.ID] = [2]int{s.Column, s.Row}
28+ }
29+ for id, cr := range want {
30+ if g, ok := got[id]; !ok || g != cr {
31+ t.Errorf("window %d: column,row %v, want %v", id, g, cr)
32+ }
33+ }
34+}
M internal/ports/state.go +4 -0
@@ -45,6 +45,10 @@ type WindowState struct {
4545 // StashIndex is the 1-based place of a stashed window in its stash of
4646 // StashCount windows; both are 0 outside the stash.
4747 StashIndex, StashCount int
48+ // Column and Row are the 1-based place of a tiled window: its column in
49+ // layout order (not geometry under fixed overflow), its row top to
50+ // bottom in that column; both are 0 for floating windows.
51+ Column, Row int
4852 // Hidden is set for a stashed window while its stash is hidden.
4953 Hidden bool
5054 }
@@ -45,6 +45,10 @@ type WindowState struct {
45 // StashIndex is the 1-based place of a stashed window in its stash of45 // StashIndex is the 1-based place of a stashed window in its stash of
46 // StashCount windows; both are 0 outside the stash.46 // StashCount windows; both are 0 outside the stash.
47 StashIndex, StashCount int47 StashIndex, StashCount int
48+ // Column and Row are the 1-based place of a tiled window: its column in
49+ // layout order (not geometry under fixed overflow), its row top to
50+ // bottom in that column; both are 0 for floating windows.
51+ Column, Row int
48 // Hidden is set for a stashed window while its stash is hidden.52 // Hidden is set for a stashed window while its stash is hidden.
49 Hidden bool53 Hidden bool
50 }54 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close