detached 96f4e50

fix(workspaces): fullscreen, presets and insertion order in fixed overflow; guard hidden moves

Fixed overflow: fullscreen covers the output and hides the other windows,
presets are ignored, new windows always go last, spiral windows keep borders.
move-to-workspace-up/down do nothing on a hidden workspace. Config warns about
binds to undeclared workspaces and hidden workspaces without a bind; the
monitor key is rejected until multi-monitor support.
commit
96f4e50e9fdcfaf36ce50ba51801546c46fd38bc
author
brice · · 2026-09-24 16:53 UTC
parent
c703b25
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +197 -37
M internal/adapters/config/config.go +33 -5
@@ -251,6 +251,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
251251 if err := scanner.Err(); err != nil {
252252 return c, raw, warnings, err
253253 }
254+ warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
254255 // A user bind on a digit (cmd+1, even "none") replaces the default bound
255256 // to the same physical key (cmd+code:2), so older configs keep working.
256257 for combo := range c.Binds {
@@ -432,6 +433,36 @@ func set(c *ports.Config, key, v string) error {
432433 return nil
433434 }
434435
436+// checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
437+// workspace (it does nothing) and a hidden workspace no bind reaches (its
438+// windows could only come back by editing the config).
439+func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
440+ declared := map[string]bool{}
441+ for _, w := range c.Workspaces {
442+ declared[w.Name] = true
443+ }
444+ bound := map[string]bool{}
445+ var warnings []Warning
446+ for combo, a := range c.Binds {
447+ name, ok := strings.CutPrefix(a, "workspace ")
448+ if !ok {
449+ continue
450+ }
451+ name = strings.TrimSpace(name)
452+ bound[name] = true
453+ if !declared[name] {
454+ warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)})
455+ }
456+ }
457+ for _, w := range c.Workspaces {
458+ if w.Hidden && !bound[w.Name] {
459+ warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
460+ }
461+ }
462+ sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
463+ return warnings
464+}
465+
435466 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
436467
437468 // setWorkspace applies one workspace.<name>.<field> key.
@@ -442,10 +473,7 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
442473 w.Hidden = b
443474 return err
444475 case "monitor":
445- if v == "" {
446- return fmt.Errorf("must not be empty")
447- }
448- w.Monitor = v
476+ return fmt.Errorf("not supported yet: one monitor only")
449477 case "max-columns":
450478 return positive(&w.MaxColumns, v, 16)
451479 case "overflow":
@@ -454,7 +482,7 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
454482 }
455483 w.Overflow = v
456484 default:
457- return fmt.Errorf("unknown key (hidden, monitor, max-columns, overflow)")
485+ return fmt.Errorf("unknown key (hidden, max-columns, overflow)")
458486 }
459487 return nil
460488 }
@@ -251,6 +251,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
251 if err := scanner.Err(); err != nil {251 if err := scanner.Err(); err != nil {
252 return c, raw, warnings, err252 return c, raw, warnings, err
253 }253 }
254+ warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
254 // A user bind on a digit (cmd+1, even "none") replaces the default bound255 // A user bind on a digit (cmd+1, even "none") replaces the default bound
255 // to the same physical key (cmd+code:2), so older configs keep working.256 // to the same physical key (cmd+code:2), so older configs keep working.
256 for combo := range c.Binds {257 for combo := range c.Binds {
@@ -432,6 +433,36 @@ func set(c *ports.Config, key, v string) error {
432 return nil433 return nil
433 }434 }
434 435
436+// checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
437+// workspace (it does nothing) and a hidden workspace no bind reaches (its
438+// windows could only come back by editing the config).
439+func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
440+ declared := map[string]bool{}
441+ for _, w := range c.Workspaces {
442+ declared[w.Name] = true
443+ }
444+ bound := map[string]bool{}
445+ var warnings []Warning
446+ for combo, a := range c.Binds {
447+ name, ok := strings.CutPrefix(a, "workspace ")
448+ if !ok {
449+ continue
450+ }
451+ name = strings.TrimSpace(name)
452+ bound[name] = true
453+ if !declared[name] {
454+ warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)})
455+ }
456+ }
457+ for _, w := range c.Workspaces {
458+ if w.Hidden && !bound[w.Name] {
459+ warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
460+ }
461+ }
462+ sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
463+ return warnings
464+}
465+
435 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)466 var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
436 467
437 // setWorkspace applies one workspace.<name>.<field> key.468 // setWorkspace applies one workspace.<name>.<field> key.
@@ -442,10 +473,7 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
442 w.Hidden = b473 w.Hidden = b
443 return err474 return err
444 case "monitor":475 case "monitor":
445- if v == "" {476+ return fmt.Errorf("not supported yet: one monitor only")
446- return fmt.Errorf("must not be empty")
447- }
448- w.Monitor = v
449 case "max-columns":477 case "max-columns":
450 return positive(&w.MaxColumns, v, 16)478 return positive(&w.MaxColumns, v, 16)
451 case "overflow":479 case "overflow":
@@ -454,7 +482,7 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
454 }482 }
455 w.Overflow = v483 w.Overflow = v
456 default:484 default:
457- return fmt.Errorf("unknown key (hidden, monitor, max-columns, overflow)")485+ return fmt.Errorf("unknown key (hidden, max-columns, overflow)")
458 }486 }
459 return nil487 return nil
460 }488 }
M internal/adapters/config/config_test.go +5 -2
@@ -245,10 +245,13 @@ workspace.dev.color = red
245245 workspace.web.overflow = spiral
246246 bind.cmd+d = workspace dev
247247 bind.cmd+w = workspace
248+bind.cmd+x = workspace typo
249+workspace.lost.hidden = on
248250 `)
249251 want := []ports.WorkspaceConfig{
250- {Name: "dev", Hidden: true, MaxColumns: 3, Monitor: "DP-2"},
252+ {Name: "dev", Hidden: true, MaxColumns: 3},
251253 {Name: "web", Overflow: "scroll"},
254+ {Name: "lost", Hidden: true},
252255 }
253256 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
254257 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
@@ -260,7 +263,7 @@ bind.cmd+w = workspace
260263 for _, x := range w {
261264 lines = append(lines, x.Line)
262265 }
263- if !reflect.DeepEqual(lines, []int{6, 7, 8, 10}) {
266+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 10, 11, 12}) {
264267 t.Fatal(w)
265268 }
266269 }
@@ -245,10 +245,13 @@ workspace.dev.color = red
245 workspace.web.overflow = spiral245 workspace.web.overflow = spiral
246 bind.cmd+d = workspace dev246 bind.cmd+d = workspace dev
247 bind.cmd+w = workspace247 bind.cmd+w = workspace
248+bind.cmd+x = workspace typo
249+workspace.lost.hidden = on
248 `)250 `)
249 want := []ports.WorkspaceConfig{251 want := []ports.WorkspaceConfig{
250- {Name: "dev", Hidden: true, MaxColumns: 3, Monitor: "DP-2"},252+ {Name: "dev", Hidden: true, MaxColumns: 3},
251 {Name: "web", Overflow: "scroll"},253 {Name: "web", Overflow: "scroll"},
254+ {Name: "lost", Hidden: true},
252 }255 }
253 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {256 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
254 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)257 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
@@ -260,7 +263,7 @@ bind.cmd+w = workspace
260 for _, x := range w {263 for _, x := range w {
261 lines = append(lines, x.Line)264 lines = append(lines, x.Line)
262 }265 }
263- if !reflect.DeepEqual(lines, []int{6, 7, 8, 10}) {266+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 10, 11, 12}) {
264 t.Fatal(w)267 t.Fatal(w)
265 }268 }
266 }269 }
M internal/core/action.go +4 -2
@@ -111,12 +111,14 @@ func (m *Monitor) Apply(a Action) Effect {
111111 }
112112 return Effect{}
113113 case ActionMoveToWorkspaceUp:
114- if m.Active > 0 {
114+ if m.shown == nil && m.Active > 0 {
115115 m.MoveToWorkspace(m.Active - 1)
116116 }
117117 return Effect{}
118118 case ActionMoveToWorkspaceDown:
119- m.MoveToWorkspace(m.Active + 1)
119+ if m.shown == nil {
120+ m.MoveToWorkspace(m.Active + 1)
121+ }
120122 return Effect{}
121123 }
122124 return m.Current().Apply(a)
@@ -111,12 +111,14 @@ func (m *Monitor) Apply(a Action) Effect {
111 }111 }
112 return Effect{}112 return Effect{}
113 case ActionMoveToWorkspaceUp:113 case ActionMoveToWorkspaceUp:
114- if m.Active > 0 {114+ if m.shown == nil && m.Active > 0 {
115 m.MoveToWorkspace(m.Active - 1)115 m.MoveToWorkspace(m.Active - 1)
116 }116 }
117 return Effect{}117 return Effect{}
118 case ActionMoveToWorkspaceDown:118 case ActionMoveToWorkspaceDown:
119- m.MoveToWorkspace(m.Active + 1)119+ if m.shown == nil {
120+ m.MoveToWorkspace(m.Active + 1)
121+ }
120 return Effect{}122 return Effect{}
121 }123 }
122 return m.Current().Apply(a)124 return m.Current().Apply(a)
M internal/core/layout.go +14 -4
@@ -142,7 +142,10 @@ func (w *Workspace) AddWindow(id WindowID) {
142142 return
143143 }
144144 at := 0
145- if len(w.Columns) > 0 {
145+ if w.Overflow == OverflowFixed {
146+ // The spiral depends only on window order: new windows go last.
147+ at = len(w.Columns)
148+ } else if len(w.Columns) > 0 {
146149 at = w.Focus + 1
147150 }
148151 w.Columns = append(w.Columns, Column{})
@@ -327,7 +330,8 @@ func (w *Workspace) columnWidth(i int) int {
327330 return w.Output.W
328331 }
329332 g := w.gap()
330- if w.Columns[i].Width == (Width{}) {
333+ // Fixed overflow never scrolls, so presets would push columns off screen.
334+ if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
331335 if len(w.Columns) == 1 {
332336 return max(w.Usable.W-2*g, 0)
333337 }
@@ -425,14 +429,20 @@ func (w *Workspace) Layout() []Placement {
425429 h = min(h, bottom-y)
426430 r := Rect{X: col.X, Y: y, W: col.W, H: h}
427431 full := w.fullscreen == id && id != 0
428- hidden := fullColumn && !full
432+ // Fixed overflow keeps every column on screen: hide them all
433+ // behind a fullscreen window.
434+ hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full
429435 if full {
436+ // Scroll mode aligns the view on the column; fixed never scrolls.
430437 r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
438+ if w.Overflow == OverflowFixed {
439+ r.X = 0
440+ }
431441 }
432442 if hidden {
433443 r = Rect{}
434444 }
435- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap})
445+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})
436446 y += h + gap
437447 }
438448 }
@@ -142,7 +142,10 @@ func (w *Workspace) AddWindow(id WindowID) {
142 return142 return
143 }143 }
144 at := 0144 at := 0
145- if len(w.Columns) > 0 {145+ if w.Overflow == OverflowFixed {
146+ // The spiral depends only on window order: new windows go last.
147+ at = len(w.Columns)
148+ } else if len(w.Columns) > 0 {
146 at = w.Focus + 1149 at = w.Focus + 1
147 }150 }
148 w.Columns = append(w.Columns, Column{})151 w.Columns = append(w.Columns, Column{})
@@ -327,7 +330,8 @@ func (w *Workspace) columnWidth(i int) int {
327 return w.Output.W330 return w.Output.W
328 }331 }
329 g := w.gap()332 g := w.gap()
330- if w.Columns[i].Width == (Width{}) {333+ // Fixed overflow never scrolls, so presets would push columns off screen.
334+ if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
331 if len(w.Columns) == 1 {335 if len(w.Columns) == 1 {
332 return max(w.Usable.W-2*g, 0)336 return max(w.Usable.W-2*g, 0)
333 }337 }
@@ -425,14 +429,20 @@ func (w *Workspace) Layout() []Placement {
425 h = min(h, bottom-y)429 h = min(h, bottom-y)
426 r := Rect{X: col.X, Y: y, W: col.W, H: h}430 r := Rect{X: col.X, Y: y, W: col.W, H: h}
427 full := w.fullscreen == id && id != 0431 full := w.fullscreen == id && id != 0
428- hidden := fullColumn && !full432+ // Fixed overflow keeps every column on screen: hide them all
433+ // behind a fullscreen window.
434+ hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full
429 if full {435 if full {
436+ // Scroll mode aligns the view on the column; fixed never scrolls.
430 r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}437 r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
438+ if w.Overflow == OverflowFixed {
439+ r.X = 0
440+ }
431 }441 }
432 if hidden {442 if hidden {
433 r = Rect{}443 r = Rect{}
434 }444 }
435- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap})445+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})
436 y += h + gap446 y += h + gap
437 }447 }
438 }448 }
M internal/core/layout_test.go +64 -2
@@ -358,8 +358,6 @@ func TestFixedOverflowSpiral(t *testing.T) {
358358 w.SetOutput(100, 80)
359359 for id := WindowID(1); id <= 5; id++ {
360360 w.AddWindow(id)
361- // New columns open after the focused one; keep order by focusing the last.
362- w.Focus = len(w.Columns) - 1
363361 }
364362 got := map[WindowID]Rect{}
365363 for _, p := range w.Layout() {
@@ -387,3 +385,67 @@ func TestFixedOverflowSpiral(t *testing.T) {
387385 t.Fatal(p)
388386 }
389387 }
388+
389+func fixedWorkspace(maxCols, gaps, n int) *Workspace {
390+ w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
391+ w.SetOutput(100, 80)
392+ for id := 1; id <= n; id++ {
393+ w.AddWindow(WindowID(id))
394+ }
395+ return w
396+}
397+
398+func placements(w *Workspace) map[WindowID]Placement {
399+ got := map[WindowID]Placement{}
400+ for _, p := range w.Layout() {
401+ got[p.ID] = p
402+ }
403+ return got
404+}
405+
406+func TestFixedOverflowEdgeCases(t *testing.T) {
407+ t.Run("new windows go last whatever the focus", func(t *testing.T) {
408+ w := fixedWorkspace(2, 0, 3)
409+ w.FocusID(1)
410+ w.AddWindow(4)
411+ if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 {
412+ t.Fatal(got)
413+ }
414+ })
415+ for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot
416+ t.Run("fullscreen covers the output", func(t *testing.T) {
417+ w := fixedWorkspace(2, 4, 4)
418+ w.FocusID(col)
419+ w.ToggleFullscreen()
420+ for id, p := range placements(w) {
421+ if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) {
422+ t.Fatal(p)
423+ }
424+ if id != col && !p.Hidden {
425+ t.Fatalf("window %d visible over fullscreen: %+v", id, p)
426+ }
427+ }
428+ })
429+ }
430+ t.Run("presets ignored", func(t *testing.T) {
431+ w := fixedWorkspace(2, 0, 2)
432+ w.SetPresets([]Width{{Num: 2, Den: 3}})
433+ w.CycleWidth()
434+ w.FocusColumn(-1)
435+ w.CycleWidth()
436+ for id, p := range placements(w) {
437+ if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
438+ t.Fatal(id, p)
439+ }
440+ }
441+ })
442+ t.Run("gaps between spiral halves", func(t *testing.T) {
443+ got := placements(fixedWorkspace(1, 4, 2))
444+ if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
445+ t.Fatal(got[1].Rect, got[2].Rect)
446+ }
447+ if got[1].Borderless || got[2].Borderless {
448+ t.Fatal("spiral windows must keep borders to show focus")
449+ }
450+ })
451+}
@@ -358,8 +358,6 @@ func TestFixedOverflowSpiral(t *testing.T) {
358 w.SetOutput(100, 80)358 w.SetOutput(100, 80)
359 for id := WindowID(1); id <= 5; id++ {359 for id := WindowID(1); id <= 5; id++ {
360 w.AddWindow(id)360 w.AddWindow(id)
361- // New columns open after the focused one; keep order by focusing the last.
362- w.Focus = len(w.Columns) - 1
363 }361 }
364 got := map[WindowID]Rect{}362 got := map[WindowID]Rect{}
365 for _, p := range w.Layout() {363 for _, p := range w.Layout() {
@@ -387,3 +385,67 @@ func TestFixedOverflowSpiral(t *testing.T) {
387 t.Fatal(p)385 t.Fatal(p)
388 }386 }
389 }387 }
388+
389+func fixedWorkspace(maxCols, gaps, n int) *Workspace {
390+ w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
391+ w.SetOutput(100, 80)
392+ for id := 1; id <= n; id++ {
393+ w.AddWindow(WindowID(id))
394+ }
395+ return w
396+}
397+
398+func placements(w *Workspace) map[WindowID]Placement {
399+ got := map[WindowID]Placement{}
400+ for _, p := range w.Layout() {
401+ got[p.ID] = p
402+ }
403+ return got
404+}
405+
406+func TestFixedOverflowEdgeCases(t *testing.T) {
407+ t.Run("new windows go last whatever the focus", func(t *testing.T) {
408+ w := fixedWorkspace(2, 0, 3)
409+ w.FocusID(1)
410+ w.AddWindow(4)
411+ if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 {
412+ t.Fatal(got)
413+ }
414+ })
415+ for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot
416+ t.Run("fullscreen covers the output", func(t *testing.T) {
417+ w := fixedWorkspace(2, 4, 4)
418+ w.FocusID(col)
419+ w.ToggleFullscreen()
420+ for id, p := range placements(w) {
421+ if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) {
422+ t.Fatal(p)
423+ }
424+ if id != col && !p.Hidden {
425+ t.Fatalf("window %d visible over fullscreen: %+v", id, p)
426+ }
427+ }
428+ })
429+ }
430+ t.Run("presets ignored", func(t *testing.T) {
431+ w := fixedWorkspace(2, 0, 2)
432+ w.SetPresets([]Width{{Num: 2, Den: 3}})
433+ w.CycleWidth()
434+ w.FocusColumn(-1)
435+ w.CycleWidth()
436+ for id, p := range placements(w) {
437+ if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
438+ t.Fatal(id, p)
439+ }
440+ }
441+ })
442+ t.Run("gaps between spiral halves", func(t *testing.T) {
443+ got := placements(fixedWorkspace(1, 4, 2))
444+ if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
445+ t.Fatal(got[1].Rect, got[2].Rect)
446+ }
447+ if got[1].Borderless || got[2].Borderless {
448+ t.Fatal("spiral windows must keep borders to show focus")
449+ }
450+ })
451+}
M internal/core/monitor.go +0 -14
@@ -160,17 +160,6 @@ func (m *Monitor) RemoveWindow(id WindowID) {
160160 }
161161 }
162162
163-// FocusID shows the window's workspace and focuses it.
164-func (m *Monitor) FocusID(id WindowID) bool {
165- w, _ := m.find(id)
166- if w == nil {
167- return false
168- }
169- m.show(w)
170- w.FocusID(id)
171- return true
172-}
173-
174163 // SetFullscreen applies a client request on the window's own workspace. It does
175164 // not switch workspaces: client requests never move the user.
176165 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
@@ -274,7 +263,6 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
274263 want[s.Name] = s
275264 }
276265 byName := map[string]*Workspace{}
277- var keep []*Workspace
278266 for _, w := range m.Workspaces {
279267 if _, ok := want[w.Name]; w.Name != "" && !ok {
280268 w.Name = ""
@@ -282,9 +270,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
282270 if w.Name != "" {
283271 byName[w.Name] = w
284272 }
285- keep = append(keep, w)
286273 }
287- m.Workspaces = keep
288274 var hidden []*Workspace
289275 for _, w := range m.hidden {
290276 if _, ok := want[w.Name]; ok {
@@ -160,17 +160,6 @@ func (m *Monitor) RemoveWindow(id WindowID) {
160 }160 }
161 }161 }
162 162
163-// FocusID shows the window's workspace and focuses it.
164-func (m *Monitor) FocusID(id WindowID) bool {
165- w, _ := m.find(id)
166- if w == nil {
167- return false
168- }
169- m.show(w)
170- w.FocusID(id)
171- return true
172-}
173-
174 // SetFullscreen applies a client request on the window's own workspace. It does163 // SetFullscreen applies a client request on the window's own workspace. It does
175 // not switch workspaces: client requests never move the user.164 // not switch workspaces: client requests never move the user.
176 func (m *Monitor) SetFullscreen(id WindowID, on bool) {165 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
@@ -274,7 +263,6 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
274 want[s.Name] = s263 want[s.Name] = s
275 }264 }
276 byName := map[string]*Workspace{}265 byName := map[string]*Workspace{}
277- var keep []*Workspace
278 for _, w := range m.Workspaces {266 for _, w := range m.Workspaces {
279 if _, ok := want[w.Name]; w.Name != "" && !ok {267 if _, ok := want[w.Name]; w.Name != "" && !ok {
280 w.Name = ""268 w.Name = ""
@@ -282,9 +270,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
282 if w.Name != "" {270 if w.Name != "" {
283 byName[w.Name] = w271 byName[w.Name] = w
284 }272 }
285- keep = append(keep, w)
286 }273 }
287- m.Workspaces = keep
288 var hidden []*Workspace274 var hidden []*Workspace
289 for _, w := range m.hidden {275 for _, w := range m.hidden {
290 if _, ok := want[w.Name]; ok {276 if _, ok := want[w.Name]; ok {
M internal/core/monitor_test.go +77 -6
@@ -118,9 +118,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
118118 t.Fatal(m.Layout())
119119 }
120120 }
121- if !m.FocusID(1) || m.Active != 0 {
122- t.Fatal(m.Active)
123- }
124121 }
125122
126123 func TestMonitorFullscreenRequestStaysPut(t *testing.T) {
@@ -278,9 +275,12 @@ func TestHiddenWorkspaceToggle(t *testing.T) {
278275 if id, _ := m.Focused(); id != 1 {
279276 t.Fatal(id)
280277 }
281- // Clicking a window of the hidden workspace shows it (FocusID).
282- if !m.FocusID(2) || m.Current().Name != "dev" {
283- t.Fatal("FocusID did not show the hidden workspace")
278+ // Moving a window up/down from a hidden workspace does nothing.
279+ m.Apply("workspace dev")
280+ m.Apply(ActionMoveToWorkspaceDown)
281+ m.Apply(ActionMoveToWorkspaceUp)
282+ if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
283+ t.Fatal(id, windows(m))
284284 }
285285 }
286286
@@ -346,3 +346,74 @@ func TestNamedWorkspaceSettings(t *testing.T) {
346346 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
347347 }
348348 }
349+
350+// checkInvariants fails when the monitor is in a state no action should reach.
351+func checkInvariants(t *testing.T, m *Monitor, want []WindowID) {
352+ t.Helper()
353+ if m.Active < 0 || m.Active >= len(m.Workspaces) {
354+ t.Fatalf("active %d of %d", m.Active, len(m.Workspaces))
355+ }
356+ if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" {
357+ t.Fatal("no trailing empty unnamed workspace")
358+ }
359+ if m.shown != nil && indexOf(m.hidden, m.shown) < 0 {
360+ t.Fatal("shown is not a hidden workspace")
361+ }
362+ if m.back != nil && !m.has(m.back) {
363+ t.Fatal("back dangles")
364+ }
365+ seen := map[WindowID]int{}
366+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
367+ for _, c := range w.Columns {
368+ for _, id := range c.Windows {
369+ seen[id]++
370+ }
371+ }
372+ }
373+ for _, id := range want {
374+ if seen[id] != 1 {
375+ t.Fatalf("window %d present %d times", id, seen[id])
376+ }
377+ }
378+ if len(seen) != len(want) {
379+ t.Fatalf("windows %v, want %v", seen, want)
380+ }
381+}
382+
383+func TestSetNamedTransitions(t *testing.T) {
384+ dev := NamedWorkspace{Name: "dev"}
385+ devHidden := NamedWorkspace{Name: "dev", Hidden: true}
386+ web := NamedWorkspace{Name: "web"}
387+ for _, tc := range []struct {
388+ name string
389+ before []NamedWorkspace
390+ after []NamedWorkspace
391+ onDev bool // show dev before the reload
392+ want string // workspace name on screen after
393+ }{
394+ {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"},
395+ {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"},
396+ {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""},
397+ {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""},
398+ {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""},
399+ {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""},
400+ } {
401+ t.Run(tc.name, func(t *testing.T) {
402+ m := named(monitor(), tc.before...)
403+ m.AddWindow(1)
404+ if tc.onDev {
405+ m.Apply("workspace dev")
406+ }
407+ m.AddWindow(2)
408+ checkInvariants(t, m, []WindowID{1, 2})
409+ m.SetNamed(tc.after)
410+ checkInvariants(t, m, []WindowID{1, 2})
411+ if got := m.Current().Name; got != tc.want {
412+ t.Fatalf("on %q, want %q", got, tc.want)
413+ }
414+ if id, _ := m.Focused(); id != 2 {
415+ t.Fatalf("focus moved to %d", id)
416+ }
417+ })
418+ }
419+}
@@ -118,9 +118,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
118 t.Fatal(m.Layout())118 t.Fatal(m.Layout())
119 }119 }
120 }120 }
121- if !m.FocusID(1) || m.Active != 0 {
122- t.Fatal(m.Active)
123- }
124 }121 }
125 122
126 func TestMonitorFullscreenRequestStaysPut(t *testing.T) {123 func TestMonitorFullscreenRequestStaysPut(t *testing.T) {
@@ -278,9 +275,12 @@ func TestHiddenWorkspaceToggle(t *testing.T) {
278 if id, _ := m.Focused(); id != 1 {275 if id, _ := m.Focused(); id != 1 {
279 t.Fatal(id)276 t.Fatal(id)
280 }277 }
281- // Clicking a window of the hidden workspace shows it (FocusID).278+ // Moving a window up/down from a hidden workspace does nothing.
282- if !m.FocusID(2) || m.Current().Name != "dev" {279+ m.Apply("workspace dev")
283- t.Fatal("FocusID did not show the hidden workspace")280+ m.Apply(ActionMoveToWorkspaceDown)
281+ m.Apply(ActionMoveToWorkspaceUp)
282+ if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
283+ t.Fatal(id, windows(m))
284 }284 }
285 }285 }
286 286
@@ -346,3 +346,74 @@ func TestNamedWorkspaceSettings(t *testing.T) {
346 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)346 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
347 }347 }
348 }348 }
349+
350+// checkInvariants fails when the monitor is in a state no action should reach.
351+func checkInvariants(t *testing.T, m *Monitor, want []WindowID) {
352+ t.Helper()
353+ if m.Active < 0 || m.Active >= len(m.Workspaces) {
354+ t.Fatalf("active %d of %d", m.Active, len(m.Workspaces))
355+ }
356+ if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" {
357+ t.Fatal("no trailing empty unnamed workspace")
358+ }
359+ if m.shown != nil && indexOf(m.hidden, m.shown) < 0 {
360+ t.Fatal("shown is not a hidden workspace")
361+ }
362+ if m.back != nil && !m.has(m.back) {
363+ t.Fatal("back dangles")
364+ }
365+ seen := map[WindowID]int{}
366+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
367+ for _, c := range w.Columns {
368+ for _, id := range c.Windows {
369+ seen[id]++
370+ }
371+ }
372+ }
373+ for _, id := range want {
374+ if seen[id] != 1 {
375+ t.Fatalf("window %d present %d times", id, seen[id])
376+ }
377+ }
378+ if len(seen) != len(want) {
379+ t.Fatalf("windows %v, want %v", seen, want)
380+ }
381+}
382+
383+func TestSetNamedTransitions(t *testing.T) {
384+ dev := NamedWorkspace{Name: "dev"}
385+ devHidden := NamedWorkspace{Name: "dev", Hidden: true}
386+ web := NamedWorkspace{Name: "web"}
387+ for _, tc := range []struct {
388+ name string
389+ before []NamedWorkspace
390+ after []NamedWorkspace
391+ onDev bool // show dev before the reload
392+ want string // workspace name on screen after
393+ }{
394+ {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"},
395+ {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"},
396+ {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""},
397+ {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""},
398+ {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""},
399+ {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""},
400+ } {
401+ t.Run(tc.name, func(t *testing.T) {
402+ m := named(monitor(), tc.before...)
403+ m.AddWindow(1)
404+ if tc.onDev {
405+ m.Apply("workspace dev")
406+ }
407+ m.AddWindow(2)
408+ checkInvariants(t, m, []WindowID{1, 2})
409+ m.SetNamed(tc.after)
410+ checkInvariants(t, m, []WindowID{1, 2})
411+ if got := m.Current().Name; got != tc.want {
412+ t.Fatalf("on %q, want %q", got, tc.want)
413+ }
414+ if id, _ := m.Focused(); id != 2 {
415+ t.Fatalf("focus moved to %d", id)
416+ }
417+ })
418+ }
419+}
M internal/ports/config.go +0 -2
@@ -58,10 +58,8 @@ type Config struct {
5858 // WorkspaceConfig declares a named workspace. Hidden ones are not numbered and
5959 // only reachable through a `workspace <name>` bind, which toggles them.
6060 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
61-// Monitor names the connector it belongs to (one monitor is supported so far).
6261 type WorkspaceConfig struct {
6362 Name string
64- Monitor string
6563 Hidden bool
6664 MaxColumns int
6765 Overflow string
@@ -58,10 +58,8 @@ type Config struct {
58 // WorkspaceConfig declares a named workspace. Hidden ones are not numbered and58 // WorkspaceConfig declares a named workspace. Hidden ones are not numbered and
59 // only reachable through a `workspace <name>` bind, which toggles them.59 // only reachable through a `workspace <name>` bind, which toggles them.
60 // Zero MaxColumns and an empty Overflow use the layout.* defaults.60 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
61-// Monitor names the connector it belongs to (one monitor is supported so far).
62 type WorkspaceConfig struct {61 type WorkspaceConfig struct {
63 Name string62 Name string
64- Monitor string
65 Hidden bool63 Hidden bool
66 MaxColumns int64 MaxColumns int
67 Overflow string65 Overflow string

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