detached d0ab56b

Merge pull request #68 from bnema/feature/unified-ui-anchoring

refactor(ui): unify overlay anchoring
commit
d0ab56bdf8fd665d8b02dec192a767897ad8edfc
author
bnema · · 2026-07-11 07:29 UTC
committer
GitHub · · 2026-07-11 07:29 UTC
merge of
8fe83c3 + 004a0de · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
26 files · +693 -216
M README.md +8 -0
@@ -49,6 +49,10 @@ vev reads `$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config` when `XDG_CON
4949 # Theme: auto follows the client; dark/light force built-in palettes.
5050 theme = auto
5151
52+# Palette placement: auto, center, top-left, top, top-right, left, right,
53+# bottom-left, bottom, or bottom-right.
54+palette.anchor = auto
55+
5256 # One prewarmed floating terminal per tab. An empty command uses the normal shell.
5357 floating.command =
5458 floating.width = 80%
@@ -114,6 +118,10 @@ Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit
114118
115119 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.
116120
121+### Command palette anchoring
122+
123+`palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.
124+
117125 ### Bar right anchors
118126
119127 The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.
@@ -49,6 +49,10 @@ vev reads `$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config` when `XDG_CON
49 # Theme: auto follows the client; dark/light force built-in palettes.49 # Theme: auto follows the client; dark/light force built-in palettes.
50 theme = auto50 theme = auto
51 51
52+# Palette placement: auto, center, top-left, top, top-right, left, right,
53+# bottom-left, bottom, or bottom-right.
54+palette.anchor = auto
55+
52 # One prewarmed floating terminal per tab. An empty command uses the normal shell.56 # One prewarmed floating terminal per tab. An empty command uses the normal shell.
53 floating.command =57 floating.command =
54 floating.width = 80%58 floating.width = 80%
@@ -114,6 +118,10 @@ Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit
114 118
115 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.119 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.
116 120
121+### Command palette anchoring
122+
123+`palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.
124+
117 ### Bar right anchors125 ### Bar right anchors
118 126
119 The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.127 The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.
M internal/adapters/config/config.go +17 -4
@@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3131 var warnings []domain.Warning
3232 seenBindingKeys := make(map[string]bool)
3333 seenFloatingKeys := make(map[string]bool)
34+ seenPaletteKeys := make(map[string]bool)
3435
3536 scanner := bufio.NewScanner(r)
3637 lineNo := 0
@@ -90,8 +91,20 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
9091 interval = domain.MinBarInterval
9192 }
9293 cfg.Bar.Interval = interval
94+ case key == "palette.anchor":
95+ warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
96+ if strings.EqualFold(strings.TrimSpace(value), "auto") {
97+ cfg.Palette = domain.PaletteConfig{}
98+ continue
99+ }
100+ anchor, ok := domain.ParseAnchor(value)
101+ if !ok {
102+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
103+ continue
104+ }
105+ cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
93106 case key == "floating.command":
94- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
107+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
95108 command, ok := parseBarCommand(value)
96109 if !ok {
97110 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
@@ -99,7 +112,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
99112 }
100113 cfg.Floating.Command = command
101114 case key == "floating.width":
102- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
115+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
103116 width, ok := parsePercentage(value)
104117 if !ok {
105118 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
@@ -107,7 +120,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
107120 }
108121 cfg.Floating.Width = width
109122 case key == "floating.height":
110- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
123+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
111124 height, ok := parsePercentage(value)
112125 if !ok {
113126 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
@@ -217,7 +230,7 @@ func fileStamp(path string) (stamp, error) {
217230 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
218231 }
219232
220-func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
233+func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
221234 if seen[key] {
222235 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
223236 }
@@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
31 var warnings []domain.Warning31 var warnings []domain.Warning
32 seenBindingKeys := make(map[string]bool)32 seenBindingKeys := make(map[string]bool)
33 seenFloatingKeys := make(map[string]bool)33 seenFloatingKeys := make(map[string]bool)
34+ seenPaletteKeys := make(map[string]bool)
34 35
35 scanner := bufio.NewScanner(r)36 scanner := bufio.NewScanner(r)
36 lineNo := 037 lineNo := 0
@@ -90,8 +91,20 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
90 interval = domain.MinBarInterval91 interval = domain.MinBarInterval
91 }92 }
92 cfg.Bar.Interval = interval93 cfg.Bar.Interval = interval
94+ case key == "palette.anchor":
95+ warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
96+ if strings.EqualFold(strings.TrimSpace(value), "auto") {
97+ cfg.Palette = domain.PaletteConfig{}
98+ continue
99+ }
100+ anchor, ok := domain.ParseAnchor(value)
101+ if !ok {
102+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
103+ continue
104+ }
105+ cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
93 case key == "floating.command":106 case key == "floating.command":
94- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)107+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
95 command, ok := parseBarCommand(value)108 command, ok := parseBarCommand(value)
96 if !ok {109 if !ok {
97 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})110 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
@@ -99,7 +112,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
99 }112 }
100 cfg.Floating.Command = command113 cfg.Floating.Command = command
101 case key == "floating.width":114 case key == "floating.width":
102- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)115+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
103 width, ok := parsePercentage(value)116 width, ok := parsePercentage(value)
104 if !ok {117 if !ok {
105 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})118 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
@@ -107,7 +120,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
107 }120 }
108 cfg.Floating.Width = width121 cfg.Floating.Width = width
109 case key == "floating.height":122 case key == "floating.height":
110- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)123+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
111 height, ok := parsePercentage(value)124 height, ok := parsePercentage(value)
112 if !ok {125 if !ok {
113 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})126 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
@@ -217,7 +230,7 @@ func fileStamp(path string) (stamp, error) {
217 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil230 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
218 }231 }
219 232
220-func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {233+func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
221 if seen[key] {234 if seen[key] {
222 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})235 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
223 }236 }
A internal/adapters/config/palette_anchor_test.go +72 -0
@@ -0,0 +1,72 @@
1+package config
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestParsePaletteAnchor(t *testing.T) {
16+ t.Parallel()
17+
18+ tests := []struct {
19+ name string
20+ input string
21+ want domain.PaletteConfig
22+ warnings []domain.Warning
23+ }{
24+ {name: "default is auto", want: domain.PaletteConfig{}},
25+ {name: "explicit auto is case insensitive", input: "palette.anchor = AuTo\n", want: domain.PaletteConfig{}},
26+ {name: "concrete anchors are trimmed and case insensitive", input: "palette.anchor = CENTER\npalette.anchor = top-left\npalette.anchor = Top\npalette.anchor = TOP-RIGHT\npalette.anchor = left\npalette.anchor = RIGHT\npalette.anchor = bottom-left\npalette.anchor = Bottom\npalette.anchor = BOTTOM-RIGHT \n", want: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 3, Msg: "duplicate key \"palette.anchor\""}, {Line: 4, Msg: "duplicate key \"palette.anchor\""}, {Line: 5, Msg: "duplicate key \"palette.anchor\""}, {Line: 6, Msg: "duplicate key \"palette.anchor\""}, {Line: 7, Msg: "duplicate key \"palette.anchor\""}, {Line: 8, Msg: "duplicate key \"palette.anchor\""}, {Line: 9, Msg: "duplicate key \"palette.anchor\""}}},
27+ {name: "invalid first preserves auto", input: "palette.anchor = diagonal\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 1, Msg: "invalid palette.anchor \"diagonal\""}}},
28+ {name: "valid then invalid preserves valid", input: "palette.anchor = top\npalette.anchor = diagonal\n", want: domain.PaletteConfig{Anchor: domain.AnchorTop, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 2, Msg: "invalid palette.anchor \"diagonal\""}}},
29+ {name: "explicit then auto clears to auto", input: "palette.anchor = left\npalette.anchor = auto\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
34+ require.NoError(t, err)
35+ require.Equal(t, tt.want, cfg.Palette)
36+ require.Equal(t, tt.warnings, warnings)
37+ require.Empty(t, cfg.BindingEntries)
38+ })
39+ }
40+}
41+
42+func TestLoadMissingPaletteAnchorDefaultsToAuto(t *testing.T) {
43+ t.Parallel()
44+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
45+ require.NoError(t, err)
46+ require.Empty(t, warnings)
47+ require.Equal(t, domain.PaletteConfig{}, cfg.Palette)
48+}
49+
50+func TestWatchPaletteAnchorRestoresAutoAfterFileRemoval(t *testing.T) {
51+ path := filepath.Join(t.TempDir(), "config")
52+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
53+ clk := newFakeClock(time.Unix(0, 0))
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ changes := make(chan domain.Config, 2)
57+ done := make(chan error, 1)
58+ go func() { done <- Watch(ctx, clk, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) }()
59+ clk.waitForTimers(t, 1)
60+
61+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
62+ clk.advance(2 * time.Second)
63+ require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
64+
65+ require.NoError(t, os.Remove(path))
66+ clk.waitForTimers(t, 1)
67+ clk.advance(2 * time.Second)
68+ got := <-changes
69+ require.Equal(t, domain.PaletteConfig{}, got.Palette)
70+ cancel()
71+ require.ErrorIs(t, <-done, context.Canceled)
72+}
@@ -0,0 +1,72 @@
1+package config
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestParsePaletteAnchor(t *testing.T) {
16+ t.Parallel()
17+
18+ tests := []struct {
19+ name string
20+ input string
21+ want domain.PaletteConfig
22+ warnings []domain.Warning
23+ }{
24+ {name: "default is auto", want: domain.PaletteConfig{}},
25+ {name: "explicit auto is case insensitive", input: "palette.anchor = AuTo\n", want: domain.PaletteConfig{}},
26+ {name: "concrete anchors are trimmed and case insensitive", input: "palette.anchor = CENTER\npalette.anchor = top-left\npalette.anchor = Top\npalette.anchor = TOP-RIGHT\npalette.anchor = left\npalette.anchor = RIGHT\npalette.anchor = bottom-left\npalette.anchor = Bottom\npalette.anchor = BOTTOM-RIGHT \n", want: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 3, Msg: "duplicate key \"palette.anchor\""}, {Line: 4, Msg: "duplicate key \"palette.anchor\""}, {Line: 5, Msg: "duplicate key \"palette.anchor\""}, {Line: 6, Msg: "duplicate key \"palette.anchor\""}, {Line: 7, Msg: "duplicate key \"palette.anchor\""}, {Line: 8, Msg: "duplicate key \"palette.anchor\""}, {Line: 9, Msg: "duplicate key \"palette.anchor\""}}},
27+ {name: "invalid first preserves auto", input: "palette.anchor = diagonal\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 1, Msg: "invalid palette.anchor \"diagonal\""}}},
28+ {name: "valid then invalid preserves valid", input: "palette.anchor = top\npalette.anchor = diagonal\n", want: domain.PaletteConfig{Anchor: domain.AnchorTop, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 2, Msg: "invalid palette.anchor \"diagonal\""}}},
29+ {name: "explicit then auto clears to auto", input: "palette.anchor = left\npalette.anchor = auto\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
34+ require.NoError(t, err)
35+ require.Equal(t, tt.want, cfg.Palette)
36+ require.Equal(t, tt.warnings, warnings)
37+ require.Empty(t, cfg.BindingEntries)
38+ })
39+ }
40+}
41+
42+func TestLoadMissingPaletteAnchorDefaultsToAuto(t *testing.T) {
43+ t.Parallel()
44+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
45+ require.NoError(t, err)
46+ require.Empty(t, warnings)
47+ require.Equal(t, domain.PaletteConfig{}, cfg.Palette)
48+}
49+
50+func TestWatchPaletteAnchorRestoresAutoAfterFileRemoval(t *testing.T) {
51+ path := filepath.Join(t.TempDir(), "config")
52+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
53+ clk := newFakeClock(time.Unix(0, 0))
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ changes := make(chan domain.Config, 2)
57+ done := make(chan error, 1)
58+ go func() { done <- Watch(ctx, clk, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) }()
59+ clk.waitForTimers(t, 1)
60+
61+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
62+ clk.advance(2 * time.Second)
63+ require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
64+
65+ require.NoError(t, os.Remove(path))
66+ clk.waitForTimers(t, 1)
67+ clk.advance(2 * time.Second)
68+ got := <-changes
69+ require.Equal(t, domain.PaletteConfig{}, got.Palette)
70+ cancel()
71+ require.ErrorIs(t, <-done, context.Canceled)
72+}
M internal/domain/config.go +8 -0
@@ -47,6 +47,13 @@ type BarConfig struct {
4747 Interval time.Duration
4848 }
4949
50+// PaletteConfig contains palette placement settings. A false AnchorSet uses the
51+// adaptive automatic placement.
52+type PaletteConfig struct {
53+ Anchor Anchor
54+ AnchorSet bool
55+}
56+
5057 // FloatingConfig contains launch and sizing settings for per-tab floating panes.
5158 type FloatingConfig struct {
5259 Command string
@@ -63,6 +70,7 @@ type Config struct {
6370 BindingEntries []ConfigEntry
6471 Codes map[string]string
6572 Snapshot SnapshotConfig
73+ Palette PaletteConfig
6674 Floating FloatingConfig
6775 }
6876
@@ -47,6 +47,13 @@ type BarConfig struct {
47 Interval time.Duration47 Interval time.Duration
48 }48 }
49 49
50+// PaletteConfig contains palette placement settings. A false AnchorSet uses the
51+// adaptive automatic placement.
52+type PaletteConfig struct {
53+ Anchor Anchor
54+ AnchorSet bool
55+}
56+
50 // FloatingConfig contains launch and sizing settings for per-tab floating panes.57 // FloatingConfig contains launch and sizing settings for per-tab floating panes.
51 type FloatingConfig struct {58 type FloatingConfig struct {
52 Command string59 Command string
@@ -63,6 +70,7 @@ type Config struct {
63 BindingEntries []ConfigEntry70 BindingEntries []ConfigEntry
64 Codes map[string]string71 Codes map[string]string
65 Snapshot SnapshotConfig72 Snapshot SnapshotConfig
73+ Palette PaletteConfig
66 Floating FloatingConfig74 Floating FloatingConfig
67 }75 }
68 76
M internal/domain/types.go +74 -0
@@ -1,5 +1,79 @@
11 package domain
22
3+import "strings"
4+
5+// Anchor specifies a position in a 3x3 grid.
6+type Anchor uint8
7+
8+const (
9+ AnchorCenter Anchor = iota
10+ AnchorTopLeft
11+ AnchorTop
12+ AnchorTopRight
13+ AnchorLeft
14+ AnchorRight
15+ AnchorBottomLeft
16+ AnchorBottom
17+ AnchorBottomRight
18+)
19+
20+// Valid reports whether a is a defined anchor.
21+func (a Anchor) Valid() bool {
22+ return a <= AnchorBottomRight
23+}
24+
25+// String returns the canonical text representation of a.
26+func (a Anchor) String() string {
27+ switch a {
28+ case AnchorCenter:
29+ return "center"
30+ case AnchorTopLeft:
31+ return "top-left"
32+ case AnchorTop:
33+ return "top"
34+ case AnchorTopRight:
35+ return "top-right"
36+ case AnchorLeft:
37+ return "left"
38+ case AnchorRight:
39+ return "right"
40+ case AnchorBottomLeft:
41+ return "bottom-left"
42+ case AnchorBottom:
43+ return "bottom"
44+ case AnchorBottomRight:
45+ return "bottom-right"
46+ default:
47+ return "unknown"
48+ }
49+}
50+
51+// ParseAnchor parses a case-insensitive anchor name.
52+func ParseAnchor(s string) (Anchor, bool) {
53+ switch strings.ToLower(strings.TrimSpace(s)) {
54+ case "center":
55+ return AnchorCenter, true
56+ case "top-left":
57+ return AnchorTopLeft, true
58+ case "top":
59+ return AnchorTop, true
60+ case "top-right":
61+ return AnchorTopRight, true
62+ case "left":
63+ return AnchorLeft, true
64+ case "right":
65+ return AnchorRight, true
66+ case "bottom-left":
67+ return AnchorBottomLeft, true
68+ case "bottom":
69+ return AnchorBottom, true
70+ case "bottom-right":
71+ return AnchorBottomRight, true
72+ default:
73+ return AnchorCenter, false
74+ }
75+}
76+
377 // Size is a terminal dimension in columns and rows.
478 type Size struct {
579 Cols, Rows int
@@ -1,5 +1,79 @@
1 package domain1 package domain
2 2
3+import "strings"
4+
5+// Anchor specifies a position in a 3x3 grid.
6+type Anchor uint8
7+
8+const (
9+ AnchorCenter Anchor = iota
10+ AnchorTopLeft
11+ AnchorTop
12+ AnchorTopRight
13+ AnchorLeft
14+ AnchorRight
15+ AnchorBottomLeft
16+ AnchorBottom
17+ AnchorBottomRight
18+)
19+
20+// Valid reports whether a is a defined anchor.
21+func (a Anchor) Valid() bool {
22+ return a <= AnchorBottomRight
23+}
24+
25+// String returns the canonical text representation of a.
26+func (a Anchor) String() string {
27+ switch a {
28+ case AnchorCenter:
29+ return "center"
30+ case AnchorTopLeft:
31+ return "top-left"
32+ case AnchorTop:
33+ return "top"
34+ case AnchorTopRight:
35+ return "top-right"
36+ case AnchorLeft:
37+ return "left"
38+ case AnchorRight:
39+ return "right"
40+ case AnchorBottomLeft:
41+ return "bottom-left"
42+ case AnchorBottom:
43+ return "bottom"
44+ case AnchorBottomRight:
45+ return "bottom-right"
46+ default:
47+ return "unknown"
48+ }
49+}
50+
51+// ParseAnchor parses a case-insensitive anchor name.
52+func ParseAnchor(s string) (Anchor, bool) {
53+ switch strings.ToLower(strings.TrimSpace(s)) {
54+ case "center":
55+ return AnchorCenter, true
56+ case "top-left":
57+ return AnchorTopLeft, true
58+ case "top":
59+ return AnchorTop, true
60+ case "top-right":
61+ return AnchorTopRight, true
62+ case "left":
63+ return AnchorLeft, true
64+ case "right":
65+ return AnchorRight, true
66+ case "bottom-left":
67+ return AnchorBottomLeft, true
68+ case "bottom":
69+ return AnchorBottom, true
70+ case "bottom-right":
71+ return AnchorBottomRight, true
72+ default:
73+ return AnchorCenter, false
74+ }
75+}
76+
3 // Size is a terminal dimension in columns and rows.77 // Size is a terminal dimension in columns and rows.
4 type Size struct {78 type Size struct {
5 Cols, Rows int79 Cols, Rows int
M internal/domain/types_test.go +65 -0
@@ -2,6 +2,71 @@ package domain
22
33 import "testing"
44
5+func TestAnchor(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ anchor Anchor
9+ valid bool
10+ text string
11+ }{
12+ {"center", AnchorCenter, true, "center"},
13+ {"top left", AnchorTopLeft, true, "top-left"},
14+ {"top", AnchorTop, true, "top"},
15+ {"top right", AnchorTopRight, true, "top-right"},
16+ {"left", AnchorLeft, true, "left"},
17+ {"right", AnchorRight, true, "right"},
18+ {"bottom left", AnchorBottomLeft, true, "bottom-left"},
19+ {"bottom", AnchorBottom, true, "bottom"},
20+ {"bottom right", AnchorBottomRight, true, "bottom-right"},
21+ {"invalid", Anchor(255), false, "unknown"},
22+ }
23+
24+ if AnchorCenter != 0 {
25+ t.Errorf("AnchorCenter = %d, want 0", AnchorCenter)
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ if got := tt.anchor.Valid(); got != tt.valid {
31+ t.Errorf("Anchor(%d).Valid() = %v, want %v", tt.anchor, got, tt.valid)
32+ }
33+ if got := tt.anchor.String(); got != tt.text {
34+ t.Errorf("Anchor(%d).String() = %q, want %q", tt.anchor, got, tt.text)
35+ }
36+ })
37+ }
38+}
39+
40+func TestParseAnchor(t *testing.T) {
41+ tests := []struct {
42+ input string
43+ want Anchor
44+ ok bool
45+ }{
46+ {"center", AnchorCenter, true},
47+ {" TOP-LEFT ", AnchorTopLeft, true},
48+ {"Top", AnchorTop, true},
49+ {"top-right", AnchorTopRight, true},
50+ {"LEFT", AnchorLeft, true},
51+ {"right", AnchorRight, true},
52+ {"bottom-left", AnchorBottomLeft, true},
53+ {"Bottom", AnchorBottom, true},
54+ {"bottom-right", AnchorBottomRight, true},
55+ {"auto", AnchorCenter, false},
56+ {"", AnchorCenter, false},
57+ {"diagonal", AnchorCenter, false},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.input, func(t *testing.T) {
62+ got, ok := ParseAnchor(tt.input)
63+ if got != tt.want || ok != tt.ok {
64+ t.Errorf("ParseAnchor(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.ok)
65+ }
66+ })
67+ }
68+}
69+
570 func TestSizeValid(t *testing.T) {
671 tests := []struct {
772 name string
@@ -2,6 +2,71 @@ package domain
2 2
3 import "testing"3 import "testing"
4 4
5+func TestAnchor(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ anchor Anchor
9+ valid bool
10+ text string
11+ }{
12+ {"center", AnchorCenter, true, "center"},
13+ {"top left", AnchorTopLeft, true, "top-left"},
14+ {"top", AnchorTop, true, "top"},
15+ {"top right", AnchorTopRight, true, "top-right"},
16+ {"left", AnchorLeft, true, "left"},
17+ {"right", AnchorRight, true, "right"},
18+ {"bottom left", AnchorBottomLeft, true, "bottom-left"},
19+ {"bottom", AnchorBottom, true, "bottom"},
20+ {"bottom right", AnchorBottomRight, true, "bottom-right"},
21+ {"invalid", Anchor(255), false, "unknown"},
22+ }
23+
24+ if AnchorCenter != 0 {
25+ t.Errorf("AnchorCenter = %d, want 0", AnchorCenter)
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ if got := tt.anchor.Valid(); got != tt.valid {
31+ t.Errorf("Anchor(%d).Valid() = %v, want %v", tt.anchor, got, tt.valid)
32+ }
33+ if got := tt.anchor.String(); got != tt.text {
34+ t.Errorf("Anchor(%d).String() = %q, want %q", tt.anchor, got, tt.text)
35+ }
36+ })
37+ }
38+}
39+
40+func TestParseAnchor(t *testing.T) {
41+ tests := []struct {
42+ input string
43+ want Anchor
44+ ok bool
45+ }{
46+ {"center", AnchorCenter, true},
47+ {" TOP-LEFT ", AnchorTopLeft, true},
48+ {"Top", AnchorTop, true},
49+ {"top-right", AnchorTopRight, true},
50+ {"LEFT", AnchorLeft, true},
51+ {"right", AnchorRight, true},
52+ {"bottom-left", AnchorBottomLeft, true},
53+ {"Bottom", AnchorBottom, true},
54+ {"bottom-right", AnchorBottomRight, true},
55+ {"auto", AnchorCenter, false},
56+ {"", AnchorCenter, false},
57+ {"diagonal", AnchorCenter, false},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.input, func(t *testing.T) {
62+ got, ok := ParseAnchor(tt.input)
63+ if got != tt.want || ok != tt.ok {
64+ t.Errorf("ParseAnchor(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.ok)
65+ }
66+ })
67+ }
68+}
69+
5 func TestSizeValid(t *testing.T) {70 func TestSizeValid(t *testing.T) {
6 tests := []struct {71 tests := []struct {
7 name string72 name string
M internal/usecase/client/reconnect_toast.go +2 -2
@@ -58,7 +58,7 @@ func reconnectToastBounds(size domain.Size) domain.Rect {
5858 }
5959
6060 func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
61- return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: ui.ToastCenter})
61+ return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter})
6262 }
6363
6464 func reconnectToastLines(bounds domain.Rect) []string {
@@ -70,7 +70,7 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
7070 return nil
7171 }
7272 frame := renderer.NewFrame(bounds.Width, bounds.Height)
73- ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: ui.ToastCenter}}}, ui.ToastStyles{
73+ ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: domain.AnchorCenter}}}, ui.ToastStyles{
7474 Text: renderer.DefaultStyle(),
7575 Box: renderer.DefaultStyle(),
7676 }, nil)
@@ -58,7 +58,7 @@ func reconnectToastBounds(size domain.Size) domain.Rect {
58 }58 }
59 59
60 func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {60 func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
61- return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: ui.ToastCenter})61+ return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter})
62 }62 }
63 63
64 func reconnectToastLines(bounds domain.Rect) []string {64 func reconnectToastLines(bounds domain.Rect) []string {
@@ -70,7 +70,7 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
70 return nil70 return nil
71 }71 }
72 frame := renderer.NewFrame(bounds.Width, bounds.Height)72 frame := renderer.NewFrame(bounds.Width, bounds.Height)
73- ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: ui.ToastCenter}}}, ui.ToastStyles{73+ ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: domain.AnchorCenter}}}, ui.ToastStyles{
74 Text: renderer.DefaultStyle(),74 Text: renderer.DefaultStyle(),
75 Box: renderer.DefaultStyle(),75 Box: renderer.DefaultStyle(),
76 }, nil)76 }, nil)
M internal/usecase/client/reconnect_toast_test.go +5 -0
@@ -245,6 +245,11 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
245245 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
246246 }
247247
248+func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
249+ bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24})
250+ require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
251+}
252+
248253 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
249254 var out bytes.Buffer
250255 size := domain.Size{Cols: 80, Rows: 24}
@@ -245,6 +245,11 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
245 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")245 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
246 }246 }
247 247
248+func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
249+ bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24})
250+ require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
251+}
252+
248 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {253 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
249 var out bytes.Buffer254 var out bytes.Buffer
250 size := domain.Size{Cols: 80, Rows: 24}255 size := domain.Size{Cols: 80, Rows: 24}
M internal/usecase/daemon/config.go +9 -0
@@ -29,6 +29,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
2929 d.restoreProcessAllowlist.Store(&allowlist)
3030 floating := cfg.Floating
3131 d.floatingConfig.Store(&floating)
32+ palette := cfg.Palette
33+ d.paletteConfig.Store(&palette)
3234 d.themeMode.Store(uint32(cfg.Theme))
3335 if d.barScripts != nil {
3436 d.barScripts.mu.Lock()
@@ -178,6 +180,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
178180 d.log.Warn("config warning", "msg", w.Msg)
179181 }
180182
183+func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
184+ if cfg := d.paletteConfig.Load(); cfg != nil {
185+ return *cfg
186+ }
187+ return domain.Defaults().Palette
188+}
189+
181190 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
182191 if cfg := d.floatingConfig.Load(); cfg != nil {
183192 return *cfg
@@ -29,6 +29,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
29 d.restoreProcessAllowlist.Store(&allowlist)29 d.restoreProcessAllowlist.Store(&allowlist)
30 floating := cfg.Floating30 floating := cfg.Floating
31 d.floatingConfig.Store(&floating)31 d.floatingConfig.Store(&floating)
32+ palette := cfg.Palette
33+ d.paletteConfig.Store(&palette)
32 d.themeMode.Store(uint32(cfg.Theme))34 d.themeMode.Store(uint32(cfg.Theme))
33 if d.barScripts != nil {35 if d.barScripts != nil {
34 d.barScripts.mu.Lock()36 d.barScripts.mu.Lock()
@@ -178,6 +180,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
178 d.log.Warn("config warning", "msg", w.Msg)180 d.log.Warn("config warning", "msg", w.Msg)
179 }181 }
180 182
183+func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
184+ if cfg := d.paletteConfig.Load(); cfg != nil {
185+ return *cfg
186+ }
187+ return domain.Defaults().Palette
188+}
189+
181 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {190 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
182 if cfg := d.floatingConfig.Load(); cfg != nil {191 if cfg := d.floatingConfig.Load(); cfg != nil {
183 return *cfg192 return *cfg
M internal/usecase/daemon/config_test.go +52 -0
@@ -6,6 +6,7 @@ import (
66 "github.com/stretchr/testify/require"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/internal/usecase/keys"
1011 )
1112
@@ -49,6 +50,57 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
4950 require.Equal(t, "NT", cmd.Code)
5051 }
5152
53+func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
54+ d := newTestDaemon(t, nil, stubClock{})
55+ require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
56+
57+ firstConfig := domain.Defaults()
58+ firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
59+ d.ApplyConfig(firstConfig)
60+ first := d.currentPaletteConfig()
61+
62+ secondConfig := domain.Defaults()
63+ secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
64+ d.ApplyConfig(secondConfig)
65+ second := d.currentPaletteConfig()
66+
67+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
68+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
69+}
70+
71+func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
72+ p, release := newBlockingPTY(t)
73+ defer release()
74+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
75+ d.enterPalette(sess, ac)
76+ awaitFrame(t, sends, ports.MsgOutput)
77+ d.handlePaletteInput(ac, []byte("new"))
78+ awaitFrame(t, sends, ports.MsgOutput)
79+ d.handlePaletteInput(ac, []byte{0x0e})
80+ awaitFrame(t, sends, ports.MsgOutput)
81+
82+ ac.overlays.paletteMu.Lock()
83+ model := ac.overlays.palette
84+ query := model.Query()
85+ selected, ok := model.Selected()
86+ ac.overlays.paletteMu.Unlock()
87+ require.True(t, ok)
88+
89+ cfg := domain.Defaults()
90+ cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
91+ d.ApplyConfig(cfg)
92+ awaitFrame(t, sends, ports.MsgOutput)
93+
94+ ac.overlays.paletteMu.Lock()
95+ defer ac.overlays.paletteMu.Unlock()
96+ require.Same(t, model, ac.overlays.palette)
97+ require.Equal(t, query, ac.overlays.palette.Query())
98+ selectedAfter, ok := ac.overlays.palette.Selected()
99+ require.True(t, ok)
100+ require.Equal(t, selected.Slug, selectedAfter.Slug)
101+ require.Equal(t, selected.Code, selectedAfter.Code)
102+}
103+
52104 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
53105 d := newTestDaemon(t, nil, stubClock{})
54106 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
@@ -6,6 +6,7 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/keys"10 "github.com/bnema/vev/internal/usecase/keys"
10 )11 )
11 12
@@ -49,6 +50,57 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
49 require.Equal(t, "NT", cmd.Code)50 require.Equal(t, "NT", cmd.Code)
50 }51 }
51 52
53+func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
54+ d := newTestDaemon(t, nil, stubClock{})
55+ require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
56+
57+ firstConfig := domain.Defaults()
58+ firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
59+ d.ApplyConfig(firstConfig)
60+ first := d.currentPaletteConfig()
61+
62+ secondConfig := domain.Defaults()
63+ secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
64+ d.ApplyConfig(secondConfig)
65+ second := d.currentPaletteConfig()
66+
67+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
68+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
69+}
70+
71+func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
72+ p, release := newBlockingPTY(t)
73+ defer release()
74+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
75+ d.enterPalette(sess, ac)
76+ awaitFrame(t, sends, ports.MsgOutput)
77+ d.handlePaletteInput(ac, []byte("new"))
78+ awaitFrame(t, sends, ports.MsgOutput)
79+ d.handlePaletteInput(ac, []byte{0x0e})
80+ awaitFrame(t, sends, ports.MsgOutput)
81+
82+ ac.overlays.paletteMu.Lock()
83+ model := ac.overlays.palette
84+ query := model.Query()
85+ selected, ok := model.Selected()
86+ ac.overlays.paletteMu.Unlock()
87+ require.True(t, ok)
88+
89+ cfg := domain.Defaults()
90+ cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
91+ d.ApplyConfig(cfg)
92+ awaitFrame(t, sends, ports.MsgOutput)
93+
94+ ac.overlays.paletteMu.Lock()
95+ defer ac.overlays.paletteMu.Unlock()
96+ require.Same(t, model, ac.overlays.palette)
97+ require.Equal(t, query, ac.overlays.palette.Query())
98+ selectedAfter, ok := ac.overlays.palette.Selected()
99+ require.True(t, ok)
100+ require.Equal(t, selected.Slug, selectedAfter.Slug)
101+ require.Equal(t, selected.Code, selectedAfter.Code)
102+}
103+
52 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {104 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
53 d := newTestDaemon(t, nil, stubClock{})105 d := newTestDaemon(t, nil, stubClock{})
54 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())106 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
M internal/usecase/daemon/copymode.go +1 -1
@@ -37,7 +37,7 @@ import (
3737 "github.com/bnema/vev/pkg/renderer"
3838 )
3939
40-var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
40+var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
4141
4242 func copyTargetPane(rt *overlayRuntime) *pane {
4343 if rt == nil {
@@ -37,7 +37,7 @@ import (
37 "github.com/bnema/vev/pkg/renderer"37 "github.com/bnema/vev/pkg/renderer"
38 )38 )
39 39
40-var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}40+var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
41 41
42 func copyTargetPane(rt *overlayRuntime) *pane {42 func copyTargetPane(rt *overlayRuntime) *pane {
43 if rt == nil {43 if rt == nil {
M internal/usecase/daemon/copymode_test.go +9 -0
@@ -18,6 +18,7 @@ import (
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 scopy "github.com/bnema/vev/internal/usecase/copy"
2020 "github.com/bnema/vev/internal/usecase/layout"
21+ "github.com/bnema/vev/internal/usecase/ui"
2122 "github.com/bnema/vev/pkg/renderer"
2223 "github.com/bnema/vev/pkg/vt"
2324 )
@@ -96,6 +97,14 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
9697 // stubClock returns timers whose channel never fires, so a scheduler under it
9798 // blocks in its debounce loop until the session context is cancelled. Used by
9899
100+func TestCopySearchModalGeometry(t *testing.T) {
101+ base := domain.Size{Cols: 100, Rows: 40}
102+
103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))
104+ require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105+ require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106+}
107+
99108 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
100109 p, release := newBlockingPTY(t)
101110 defer release()
@@ -18,6 +18,7 @@ import (
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
20 "github.com/bnema/vev/internal/usecase/layout"20 "github.com/bnema/vev/internal/usecase/layout"
21+ "github.com/bnema/vev/internal/usecase/ui"
21 "github.com/bnema/vev/pkg/renderer"22 "github.com/bnema/vev/pkg/renderer"
22 "github.com/bnema/vev/pkg/vt"23 "github.com/bnema/vev/pkg/vt"
23 )24 )
@@ -96,6 +97,14 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
96 // stubClock returns timers whose channel never fires, so a scheduler under it97 // stubClock returns timers whose channel never fires, so a scheduler under it
97 // blocks in its debounce loop until the session context is cancelled. Used by98 // blocks in its debounce loop until the session context is cancelled. Used by
98 99
100+func TestCopySearchModalGeometry(t *testing.T) {
101+ base := domain.Size{Cols: 100, Rows: 40}
102+
103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))
104+ require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105+ require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106+}
107+
99 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {108 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
100 p, release := newBlockingPTY(t)109 p, release := newBlockingPTY(t)
101 defer release()110 defer release()
M internal/usecase/daemon/daemon.go +5 -1
@@ -136,6 +136,7 @@ type Daemon struct {
136136 codeOverrides atomic.Pointer[map[string]string]
137137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139+ paletteConfig atomic.Pointer[domain.PaletteConfig]
139140 themeMode atomic.Uint32
140141 barScripts *barScriptState
141142 resumeParkGrace time.Duration
@@ -316,8 +317,11 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
316317 running: make(map[domain.SessionID]bool),
317318 },
318319 }
319- defaultFloating := domain.Defaults().Floating
320+ defaults := domain.Defaults()
321+ defaultFloating := defaults.Floating
320322 d.floatingConfig.Store(&defaultFloating)
323+ defaultPalette := defaults.Palette
324+ d.paletteConfig.Store(&defaultPalette)
321325 for _, o := range opts {
322326 o(d)
323327 }
@@ -136,6 +136,7 @@ type Daemon struct {
136 codeOverrides atomic.Pointer[map[string]string]136 codeOverrides atomic.Pointer[map[string]string]
137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138 floatingConfig atomic.Pointer[domain.FloatingConfig]138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139+ paletteConfig atomic.Pointer[domain.PaletteConfig]
139 themeMode atomic.Uint32140 themeMode atomic.Uint32
140 barScripts *barScriptState141 barScripts *barScriptState
141 resumeParkGrace time.Duration142 resumeParkGrace time.Duration
@@ -316,8 +317,11 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
316 running: make(map[domain.SessionID]bool),317 running: make(map[domain.SessionID]bool),
317 },318 },
318 }319 }
319- defaultFloating := domain.Defaults().Floating320+ defaults := domain.Defaults()
321+ defaultFloating := defaults.Floating
320 d.floatingConfig.Store(&defaultFloating)322 d.floatingConfig.Store(&defaultFloating)
323+ defaultPalette := defaults.Palette
324+ d.paletteConfig.Store(&defaultPalette)
321 for _, o := range opts {325 for _, o := range opts {
322 o(d)326 o(d)
323 }327 }
M internal/usecase/daemon/palette.go +9 -6
@@ -14,14 +14,17 @@ const (
1414 paletteRailWidth = 64
1515 )
1616
17-var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1}
17+var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
1818
19-func paletteModalFor(size domain.Size) ui.Modal {
19+func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
2020 modal := paletteModal
2121 if size.Cols >= paletteRailBreakpoint {
2222 modal.FixedWidth = paletteRailWidth
23- modal.HorizontalAnchor = ui.HorizontalAnchorRight
24- modal.RightMargin = 1
23+ }
24+ if cfg.AnchorSet {
25+ modal.Anchor = cfg.Anchor
26+ } else if size.Cols >= paletteRailBreakpoint {
27+ modal.Anchor = domain.AnchorBottomRight
2528 }
2629 return modal
2730 }
@@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error {
298301 return nil
299302 }
300303
301-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
304+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
302305 styleSet := resolveThemeStyles(styles)
303- modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height})
306+ modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
304307 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
305308 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
306309 })
@@ -14,14 +14,17 @@ const (
14 paletteRailWidth = 6414 paletteRailWidth = 64
15 )15 )
16 16
17-var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1}17+var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
18 18
19-func paletteModalFor(size domain.Size) ui.Modal {19+func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
20 modal := paletteModal20 modal := paletteModal
21 if size.Cols >= paletteRailBreakpoint {21 if size.Cols >= paletteRailBreakpoint {
22 modal.FixedWidth = paletteRailWidth22 modal.FixedWidth = paletteRailWidth
23- modal.HorizontalAnchor = ui.HorizontalAnchorRight23+ }
24- modal.RightMargin = 124+ if cfg.AnchorSet {
25+ modal.Anchor = cfg.Anchor
26+ } else if size.Cols >= paletteRailBreakpoint {
27+ modal.Anchor = domain.AnchorBottomRight
25 }28 }
26 return modal29 return modal
27 }30 }
@@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error {
298 return nil301 return nil
299 }302 }
300 303
301-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {304+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
302 styleSet := resolveThemeStyles(styles)305 styleSet := resolveThemeStyles(styles)
303- modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height})306+ modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
304 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {307 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
305 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})308 return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
306 })309 })
M internal/usecase/daemon/palette_test.go +40 -9
@@ -11,7 +11,9 @@ import (
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/palette"
1415 "github.com/bnema/vev/internal/usecase/ui"
16+ "github.com/bnema/vev/pkg/renderer"
1517 )
1618
1719 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
@@ -241,25 +243,54 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
241243 }
242244
243245 func TestPaletteModalGeometry(t *testing.T) {
244- tests := []struct {
246+ type testCase struct {
245247 name string
246248 size domain.Size
249+ cfg domain.PaletteConfig
247250 want domain.Rect
248- }{
249- {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
250- {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
251- {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
252- {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
251+ }
252+
253+ auto := domain.PaletteConfig{}
254+ tests := []testCase{
255+ {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
256+ {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
257+ {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
258+ {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
259+ }
260+ for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
261+ wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
262+ wantY := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 1, domain.AnchorTopRight: 1, domain.AnchorLeft: 14, domain.AnchorCenter: 14, domain.AnchorRight: 14, domain.AnchorBottomLeft: 28, domain.AnchorBottom: 28, domain.AnchorBottomRight: 28}[anchor]
263+ tests = append(tests, testCase{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})
264+ }
265+ for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
266+ tests = append(tests, testCase{name: "narrow " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: 0, Y: 14, Width: 95, Height: 11}})
253267 }
254268
255269 for _, tt := range tests {
256270 t.Run(tt.name, func(t *testing.T) {
257- modal := paletteModalFor(tt.size)
271+ modal := paletteModalFor(tt.size, tt.cfg)
258272 require.Equal(t, tt.want, modal.Bounds(tt.size))
259273 require.Equal(t, " Commands ", modal.Title)
260274 require.Equal(t, 11, modal.FixedHeight)
261- require.Equal(t, ui.AnchorBottom, modal.Anchor)
262- require.Equal(t, 1, modal.BottomMargin)
275+ require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
276+ })
277+ }
278+}
279+
280+func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
281+ model := palette.New(nil)
282+ base := renderer.NewFrame(120, 40)
283+ for _, tt := range []struct {
284+ name string
285+ cfg domain.PaletteConfig
286+ at domain.Rect
287+ }{
288+ {name: "top left", cfg: domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, at: domain.Rect{X: 1, Y: 1}},
289+ {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
290+ } {
291+ t.Run(tt.name, func(t *testing.T) {
292+ frame, _ := composePaletteClientFrame(model, base, tt.cfg)
293+ require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
263294 })
264295 }
265296 }
@@ -11,7 +11,9 @@ import (
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/palette"
14 "github.com/bnema/vev/internal/usecase/ui"15 "github.com/bnema/vev/internal/usecase/ui"
16+ "github.com/bnema/vev/pkg/renderer"
15 )17 )
16 18
17 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {19 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
@@ -241,25 +243,54 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
241 }243 }
242 244
243 func TestPaletteModalGeometry(t *testing.T) {245 func TestPaletteModalGeometry(t *testing.T) {
244- tests := []struct {246+ type testCase struct {
245 name string247 name string
246 size domain.Size248 size domain.Size
249+ cfg domain.PaletteConfig
247 want domain.Rect250 want domain.Rect
248- }{251+ }
249- {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},252+
250- {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},253+ auto := domain.PaletteConfig{}
251- {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},254+ tests := []testCase{
252- {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},255+ {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
256+ {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
257+ {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
258+ {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},
259+ }
260+ for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
261+ wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
262+ wantY := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 1, domain.AnchorTopRight: 1, domain.AnchorLeft: 14, domain.AnchorCenter: 14, domain.AnchorRight: 14, domain.AnchorBottomLeft: 28, domain.AnchorBottom: 28, domain.AnchorBottomRight: 28}[anchor]
263+ tests = append(tests, testCase{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})
264+ }
265+ for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
266+ tests = append(tests, testCase{name: "narrow " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: 0, Y: 14, Width: 95, Height: 11}})
253 }267 }
254 268
255 for _, tt := range tests {269 for _, tt := range tests {
256 t.Run(tt.name, func(t *testing.T) {270 t.Run(tt.name, func(t *testing.T) {
257- modal := paletteModalFor(tt.size)271+ modal := paletteModalFor(tt.size, tt.cfg)
258 require.Equal(t, tt.want, modal.Bounds(tt.size))272 require.Equal(t, tt.want, modal.Bounds(tt.size))
259 require.Equal(t, " Commands ", modal.Title)273 require.Equal(t, " Commands ", modal.Title)
260 require.Equal(t, 11, modal.FixedHeight)274 require.Equal(t, 11, modal.FixedHeight)
261- require.Equal(t, ui.AnchorBottom, modal.Anchor)275+ require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
262- require.Equal(t, 1, modal.BottomMargin)276+ })
277+ }
278+}
279+
280+func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
281+ model := palette.New(nil)
282+ base := renderer.NewFrame(120, 40)
283+ for _, tt := range []struct {
284+ name string
285+ cfg domain.PaletteConfig
286+ at domain.Rect
287+ }{
288+ {name: "top left", cfg: domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, at: domain.Rect{X: 1, Y: 1}},
289+ {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
290+ } {
291+ t.Run(tt.name, func(t *testing.T) {
292+ frame, _ := composePaletteClientFrame(model, base, tt.cfg)
293+ require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
263 })294 })
264 }295 }
265 }296 }
M internal/usecase/daemon/picker.go +1 -1
@@ -37,7 +37,7 @@ import (
3737 "github.com/bnema/vev/pkg/renderer"
3838 )
3939
40-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
40+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
4141
4242 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
4343 views, curTab := d.pickerViews(sess)
@@ -37,7 +37,7 @@ import (
37 "github.com/bnema/vev/pkg/renderer"37 "github.com/bnema/vev/pkg/renderer"
38 )38 )
39 39
40-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}40+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
41 41
42 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {42 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
43 views, curTab := d.pickerViews(sess)43 views, curTab := d.pickerViews(sess)
M internal/usecase/daemon/picker_test.go +7 -0
@@ -516,6 +516,13 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
516516 drain.Wait()
517517 }
518518
519+func TestPickerModalGeometry(t *testing.T) {
520+ base := domain.Size{Cols: 100, Rows: 40}
521+
522+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))
523+ require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
524+}
525+
519526 func TestPickerResizeRecomposesModal(t *testing.T) {
520527 p, releasePTY := newBlockingPTY(t)
521528 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -516,6 +516,13 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
516 drain.Wait()516 drain.Wait()
517 }517 }
518 518
519+func TestPickerModalGeometry(t *testing.T) {
520+ base := domain.Size{Cols: 100, Rows: 40}
521+
522+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))
523+ require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
524+}
525+
519 func TestPickerResizeRecomposesModal(t *testing.T) {526 func TestPickerResizeRecomposesModal(t *testing.T) {
520 p, releasePTY := newBlockingPTY(t)527 p, releasePTY := newBlockingPTY(t)
521 d, sess, ac, sends := newManualSessionWithPTYs(t, p)528 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/prompt.go +2 -1
@@ -3,13 +3,14 @@ package daemon
33 import (
44 "strings"
55
6+ "github.com/bnema/vev/internal/domain"
67 promptui "github.com/bnema/vev/internal/usecase/prompt"
78 "github.com/bnema/vev/internal/usecase/ui"
89 "github.com/bnema/vev/pkg/renderer"
910 )
1011
1112 func promptModalFor(title string) ui.Modal {
12- return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: ui.AnchorBottom, BottomMargin: 1}
13+ return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
1314 }
1415
1516 func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
@@ -3,13 +3,14 @@ package daemon
3 import (3 import (
4 "strings"4 "strings"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 promptui "github.com/bnema/vev/internal/usecase/prompt"7 promptui "github.com/bnema/vev/internal/usecase/prompt"
7 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
8 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
9 )10 )
10 11
11 func promptModalFor(title string) ui.Modal {12 func promptModalFor(title string) ui.Modal {
12- return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: ui.AnchorBottom, BottomMargin: 1}13+ return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
13 }14 }
14 15
15 func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {16 func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
M internal/usecase/daemon/prompt_test.go +7 -0
@@ -4,10 +4,17 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/ports"
89 "github.com/stretchr/testify/require"
910 )
1011
12+func TestPromptModalGeometry(t *testing.T) {
13+ base := domain.Size{Cols: 100, Rows: 40}
14+
15+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))
16+}
17+
1118 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
1219 p, release := newBlockingPTY(t)
1320 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -4,10 +4,17 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 )10 )
10 11
12+func TestPromptModalGeometry(t *testing.T) {
13+ base := domain.Size{Cols: 100, Rows: 40}
14+
15+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))
16+}
17+
11 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {18 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
12 p, release := newBlockingPTY(t)19 p, release := newBlockingPTY(t)
13 d, sess, ac, sends := newManualSessionWithPTYs(t, p)20 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/render.go +2 -1
@@ -350,6 +350,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
350350 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
351351
352352 styles := newThemeStyles(ac.getTheme())
353+ paletteCfg := d.currentPaletteConfig()
353354 tb.mu.Lock()
354355 layoutSnap := solveTabLayoutLocked(tb)
355356 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
@@ -429,7 +430,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
429430 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
430431 }
431432 if overlays.paletteActive {
432- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles)
433+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles)
433434 }
434435 if overlays.promptActive {
435436 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
@@ -350,6 +350,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
350 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)350 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
351 351
352 styles := newThemeStyles(ac.getTheme())352 styles := newThemeStyles(ac.getTheme())
353+ paletteCfg := d.currentPaletteConfig()
353 tb.mu.Lock()354 tb.mu.Lock()
354 layoutSnap := solveTabLayoutLocked(tb)355 layoutSnap := solveTabLayoutLocked(tb)
355 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)356 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
@@ -429,7 +430,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
429 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)430 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
430 }431 }
431 if overlays.paletteActive {432 if overlays.paletteActive {
432- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles)433+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles)
433 }434 }
434 if overlays.promptActive {435 if overlays.promptActive {
435 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)436 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
M internal/usecase/ui/modal.go +10 -63
@@ -5,47 +5,21 @@ import (
55 "github.com/bnema/vev/pkg/renderer"
66 )
77
8-// Anchor describes where a modal is positioned within its base rectangle.
9-type Anchor int
10-
11-const (
12- // AnchorCenter centers the modal within the base rectangle.
13- AnchorCenter Anchor = iota
14- // AnchorBottom positions the modal at the bottom above BottomMargin rows.
15- AnchorBottom
16-)
17-
18-// HorizontalAnchor describes a modal's horizontal position within its base rectangle.
19-type HorizontalAnchor int
20-
21-const (
22- // HorizontalAnchorCenter centers the modal within the base rectangle.
23- HorizontalAnchorCenter HorizontalAnchor = iota
24- // HorizontalAnchorRight positions the modal left of RightMargin columns.
25- HorizontalAnchorRight
26-)
27-
288 // Modal describes a rectangular overlay.
299 type Modal struct {
30- WidthPct int
31- HeightPct int
32- MinWidth int
33- MinHeight int
34- Title string
35- Anchor Anchor
36- HorizontalAnchor HorizontalAnchor
37- FixedWidth int
38- FixedHeight int
39- BottomMargin int
40- RightMargin int
10+ WidthPct int
11+ HeightPct int
12+ MinWidth int
13+ MinHeight int
14+ Title string
15+ Anchor domain.Anchor
16+ Margins Margins
17+ FixedWidth int
18+ FixedHeight int
4119 }
4220
4321 // Bounds returns the modal rectangle positioned within base and clamped to base.
4422 func (m Modal) Bounds(base domain.Size) domain.Rect {
45- if base.Cols <= 0 || base.Rows <= 0 {
46- return domain.Rect{}
47- }
48-
4923 width := percentOf(base.Cols, m.WidthPct)
5024 height := percentOf(base.Rows, m.HeightPct)
5125 if m.FixedWidth > 0 {
@@ -60,24 +34,7 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
6034 if height < m.MinHeight {
6135 height = m.MinHeight
6236 }
63- width = clamp(width, 0, base.Cols)
64- height = clamp(height, 0, base.Rows)
65-
66- x := (base.Cols - width) / 2
67- if m.HorizontalAnchor == HorizontalAnchorRight {
68- x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width)
69- }
70- y := (base.Rows - height) / 2
71- if m.Anchor == AnchorBottom {
72- y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height)
73- }
74-
75- return domain.Rect{
76- X: x,
77- Y: y,
78- Width: width,
79- Height: height,
80- }
37+ return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
8138 }
8239
8340 // Inner returns the modal content rectangle after removing a one-cell border.
@@ -193,16 +150,6 @@ func percentOf(n, pct int) int {
193150 return n * pct / 100
194151 }
195152
196-func clamp(n, low, high int) int {
197- if n < low {
198- return low
199- }
200- if n > high {
201- return high
202- }
203- return n
204-}
205-
206153 func textWidth(text string) int {
207154 width := 0
208155 for _, r := range text {
@@ -5,47 +5,21 @@ import (
5 "github.com/bnema/vev/pkg/renderer"5 "github.com/bnema/vev/pkg/renderer"
6 )6 )
7 7
8-// Anchor describes where a modal is positioned within its base rectangle.
9-type Anchor int
10-
11-const (
12- // AnchorCenter centers the modal within the base rectangle.
13- AnchorCenter Anchor = iota
14- // AnchorBottom positions the modal at the bottom above BottomMargin rows.
15- AnchorBottom
16-)
17-
18-// HorizontalAnchor describes a modal's horizontal position within its base rectangle.
19-type HorizontalAnchor int
20-
21-const (
22- // HorizontalAnchorCenter centers the modal within the base rectangle.
23- HorizontalAnchorCenter HorizontalAnchor = iota
24- // HorizontalAnchorRight positions the modal left of RightMargin columns.
25- HorizontalAnchorRight
26-)
27-
28 // Modal describes a rectangular overlay.8 // Modal describes a rectangular overlay.
29 type Modal struct {9 type Modal struct {
30- WidthPct int10+ WidthPct int
31- HeightPct int11+ HeightPct int
32- MinWidth int12+ MinWidth int
33- MinHeight int13+ MinHeight int
34- Title string14+ Title string
35- Anchor Anchor15+ Anchor domain.Anchor
36- HorizontalAnchor HorizontalAnchor16+ Margins Margins
37- FixedWidth int17+ FixedWidth int
38- FixedHeight int18+ FixedHeight int
39- BottomMargin int
40- RightMargin int
41 }19 }
42 20
43 // Bounds returns the modal rectangle positioned within base and clamped to base.21 // Bounds returns the modal rectangle positioned within base and clamped to base.
44 func (m Modal) Bounds(base domain.Size) domain.Rect {22 func (m Modal) Bounds(base domain.Size) domain.Rect {
45- if base.Cols <= 0 || base.Rows <= 0 {
46- return domain.Rect{}
47- }
48-
49 width := percentOf(base.Cols, m.WidthPct)23 width := percentOf(base.Cols, m.WidthPct)
50 height := percentOf(base.Rows, m.HeightPct)24 height := percentOf(base.Rows, m.HeightPct)
51 if m.FixedWidth > 0 {25 if m.FixedWidth > 0 {
@@ -60,24 +34,7 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
60 if height < m.MinHeight {34 if height < m.MinHeight {
61 height = m.MinHeight35 height = m.MinHeight
62 }36 }
63- width = clamp(width, 0, base.Cols)37+ return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
64- height = clamp(height, 0, base.Rows)
65-
66- x := (base.Cols - width) / 2
67- if m.HorizontalAnchor == HorizontalAnchorRight {
68- x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width)
69- }
70- y := (base.Rows - height) / 2
71- if m.Anchor == AnchorBottom {
72- y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height)
73- }
74-
75- return domain.Rect{
76- X: x,
77- Y: y,
78- Width: width,
79- Height: height,
80- }
81 }38 }
82 39
83 // Inner returns the modal content rectangle after removing a one-cell border.40 // Inner returns the modal content rectangle after removing a one-cell border.
@@ -193,16 +150,6 @@ func percentOf(n, pct int) int {
193 return n * pct / 100150 return n * pct / 100
194 }151 }
195 152
196-func clamp(n, low, high int) int {
197- if n < low {
198- return low
199- }
200- if n > high {
201- return high
202- }
203- return n
204-}
205-
206 func textWidth(text string) int {153 func textWidth(text string) int {
207 width := 0154 width := 0
208 for _, r := range text {155 for _, r := range text {
M internal/usecase/ui/modal_test.go +26 -15
@@ -41,7 +41,7 @@ func TestModalBounds(t *testing.T) {
4141 {
4242 name: "bottom anchor honors bottom margin",
4343 base: domain.Size{Cols: 100, Rows: 40},
44- m: Modal{WidthPct: 50, HeightPct: 25, Anchor: AnchorBottom, BottomMargin: 3},
44+ m: Modal{WidthPct: 50, HeightPct: 25, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 3}},
4545 want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10},
4646 },
4747 {
@@ -54,10 +54,10 @@ func TestModalBounds(t *testing.T) {
5454 name: "right anchor honors right margin",
5555 base: domain.Size{Cols: 100, Rows: 40},
5656 m: Modal{
57- FixedWidth: 30,
58- FixedHeight: 12,
59- HorizontalAnchor: HorizontalAnchorRight,
60- RightMargin: 3,
57+ FixedWidth: 30,
58+ FixedHeight: 12,
59+ Anchor: domain.AnchorRight,
60+ Margins: Margins{Right: 3},
6161 },
6262 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
6363 },
@@ -65,10 +65,10 @@ func TestModalBounds(t *testing.T) {
6565 name: "right margin clamps to screen",
6666 base: domain.Size{Cols: 20, Rows: 10},
6767 m: Modal{
68- FixedWidth: 8,
69- FixedHeight: 4,
70- HorizontalAnchor: HorizontalAnchorRight,
71- RightMargin: 20,
68+ FixedWidth: 8,
69+ FixedHeight: 4,
70+ Anchor: domain.AnchorRight,
71+ Margins: Margins{Right: 20},
7272 },
7373 want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},
7474 },
@@ -76,25 +76,36 @@ func TestModalBounds(t *testing.T) {
7676 name: "right anchored fixed dimensions clamp on small terminal",
7777 base: domain.Size{Cols: 10, Rows: 4},
7878 m: Modal{
79- FixedWidth: 30,
80- FixedHeight: 12,
81- HorizontalAnchor: HorizontalAnchorRight,
82- RightMargin: 3,
79+ FixedWidth: 30,
80+ FixedHeight: 12,
81+ Anchor: domain.AnchorRight,
82+ Margins: Margins{Right: 3},
8383 },
8484 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
8585 },
8686 {
8787 name: "fixed dimensions clamp on small terminal",
8888 base: domain.Size{Cols: 10, Rows: 4},
89- m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: AnchorBottom, BottomMargin: 2},
89+ m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
9090 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
9191 },
9292 {
9393 name: "bottom margin clamps to screen",
9494 base: domain.Size{Cols: 20, Rows: 10},
95- m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: AnchorBottom, BottomMargin: 20},
95+ m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
9696 want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},
9797 },
98+ {
99+ name: "bottom right anchor honors combined margins",
100+ base: domain.Size{Cols: 100, Rows: 40},
101+ m: Modal{
102+ FixedWidth: 30,
103+ FixedHeight: 12,
104+ Anchor: domain.AnchorBottomRight,
105+ Margins: Margins{Bottom: 3, Right: 5},
106+ },
107+ want: domain.Rect{X: 65, Y: 25, Width: 30, Height: 12},
108+ },
98109 }
99110 for _, tt := range tests {
100111 t.Run(tt.name, func(t *testing.T) {
@@ -41,7 +41,7 @@ func TestModalBounds(t *testing.T) {
41 {41 {
42 name: "bottom anchor honors bottom margin",42 name: "bottom anchor honors bottom margin",
43 base: domain.Size{Cols: 100, Rows: 40},43 base: domain.Size{Cols: 100, Rows: 40},
44- m: Modal{WidthPct: 50, HeightPct: 25, Anchor: AnchorBottom, BottomMargin: 3},44+ m: Modal{WidthPct: 50, HeightPct: 25, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 3}},
45 want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10},45 want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10},
46 },46 },
47 {47 {
@@ -54,10 +54,10 @@ func TestModalBounds(t *testing.T) {
54 name: "right anchor honors right margin",54 name: "right anchor honors right margin",
55 base: domain.Size{Cols: 100, Rows: 40},55 base: domain.Size{Cols: 100, Rows: 40},
56 m: Modal{56 m: Modal{
57- FixedWidth: 30,57+ FixedWidth: 30,
58- FixedHeight: 12,58+ FixedHeight: 12,
59- HorizontalAnchor: HorizontalAnchorRight,59+ Anchor: domain.AnchorRight,
60- RightMargin: 3,60+ Margins: Margins{Right: 3},
61 },61 },
62 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},62 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
63 },63 },
@@ -65,10 +65,10 @@ func TestModalBounds(t *testing.T) {
65 name: "right margin clamps to screen",65 name: "right margin clamps to screen",
66 base: domain.Size{Cols: 20, Rows: 10},66 base: domain.Size{Cols: 20, Rows: 10},
67 m: Modal{67 m: Modal{
68- FixedWidth: 8,68+ FixedWidth: 8,
69- FixedHeight: 4,69+ FixedHeight: 4,
70- HorizontalAnchor: HorizontalAnchorRight,70+ Anchor: domain.AnchorRight,
71- RightMargin: 20,71+ Margins: Margins{Right: 20},
72 },72 },
73 want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},73 want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},
74 },74 },
@@ -76,25 +76,36 @@ func TestModalBounds(t *testing.T) {
76 name: "right anchored fixed dimensions clamp on small terminal",76 name: "right anchored fixed dimensions clamp on small terminal",
77 base: domain.Size{Cols: 10, Rows: 4},77 base: domain.Size{Cols: 10, Rows: 4},
78 m: Modal{78 m: Modal{
79- FixedWidth: 30,79+ FixedWidth: 30,
80- FixedHeight: 12,80+ FixedHeight: 12,
81- HorizontalAnchor: HorizontalAnchorRight,81+ Anchor: domain.AnchorRight,
82- RightMargin: 3,82+ Margins: Margins{Right: 3},
83 },83 },
84 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},84 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
85 },85 },
86 {86 {
87 name: "fixed dimensions clamp on small terminal",87 name: "fixed dimensions clamp on small terminal",
88 base: domain.Size{Cols: 10, Rows: 4},88 base: domain.Size{Cols: 10, Rows: 4},
89- m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: AnchorBottom, BottomMargin: 2},89+ m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
90 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},90 want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
91 },91 },
92 {92 {
93 name: "bottom margin clamps to screen",93 name: "bottom margin clamps to screen",
94 base: domain.Size{Cols: 20, Rows: 10},94 base: domain.Size{Cols: 20, Rows: 10},
95- m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: AnchorBottom, BottomMargin: 20},95+ m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
96 want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},96 want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},
97 },97 },
98+ {
99+ name: "bottom right anchor honors combined margins",
100+ base: domain.Size{Cols: 100, Rows: 40},
101+ m: Modal{
102+ FixedWidth: 30,
103+ FixedHeight: 12,
104+ Anchor: domain.AnchorBottomRight,
105+ Margins: Margins{Bottom: 3, Right: 5},
106+ },
107+ want: domain.Rect{X: 65, Y: 25, Width: 30, Height: 12},
108+ },
98 }109 }
99 for _, tt := range tests {110 for _, tt := range tests {
100 t.Run(tt.name, func(t *testing.T) {111 t.Run(tt.name, func(t *testing.T) {
A internal/usecase/ui/place.go +66 -0
@@ -0,0 +1,66 @@
1+package ui
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Margins specifies the distance between an anchored rectangle and each base edge.
6+type Margins struct {
7+ Top, Right, Bottom, Left int
8+}
9+
10+// Place positions content in base according to anchor. Negative margins and
11+// content dimensions are normalized to zero. The returned rectangle is always
12+// contained within a positive base; a nonpositive base returns an empty rect.
13+func Place(base, content domain.Size, anchor domain.Anchor, margins Margins) domain.Rect {
14+ if !base.Valid() {
15+ return domain.Rect{}
16+ }
17+
18+ width := clamp(content.Cols, 0, base.Cols)
19+ height := clamp(content.Rows, 0, base.Rows)
20+ margins.Top = max(0, margins.Top)
21+ margins.Right = max(0, margins.Right)
22+ margins.Bottom = max(0, margins.Bottom)
23+ margins.Left = max(0, margins.Left)
24+
25+ x := (base.Cols - width) / 2
26+ y := (base.Rows - height) / 2
27+ switch anchor {
28+ case domain.AnchorTopLeft:
29+ x, y = margins.Left, margins.Top
30+ case domain.AnchorTop:
31+ y = margins.Top
32+ case domain.AnchorTopRight:
33+ x, y = base.Cols-margins.Right-width, margins.Top
34+ case domain.AnchorLeft:
35+ x = margins.Left
36+ case domain.AnchorRight:
37+ x = base.Cols - margins.Right - width
38+ case domain.AnchorBottomLeft:
39+ x, y = margins.Left, base.Rows-margins.Bottom-height
40+ case domain.AnchorBottom:
41+ y = base.Rows - margins.Bottom - height
42+ case domain.AnchorBottomRight:
43+ x, y = base.Cols-margins.Right-width, base.Rows-margins.Bottom-height
44+ case domain.AnchorCenter:
45+ // Center is the zero value and intentionally ignores all margins.
46+ default:
47+ // Invalid anchors fall back to center.
48+ }
49+
50+ return domain.Rect{
51+ X: clamp(x, 0, base.Cols-width),
52+ Y: clamp(y, 0, base.Rows-height),
53+ Width: width,
54+ Height: height,
55+ }
56+}
57+
58+func clamp(n, low, high int) int {
59+ if n < low {
60+ return low
61+ }
62+ if n > high {
63+ return high
64+ }
65+ return n
66+}
@@ -0,0 +1,66 @@
1+package ui
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Margins specifies the distance between an anchored rectangle and each base edge.
6+type Margins struct {
7+ Top, Right, Bottom, Left int
8+}
9+
10+// Place positions content in base according to anchor. Negative margins and
11+// content dimensions are normalized to zero. The returned rectangle is always
12+// contained within a positive base; a nonpositive base returns an empty rect.
13+func Place(base, content domain.Size, anchor domain.Anchor, margins Margins) domain.Rect {
14+ if !base.Valid() {
15+ return domain.Rect{}
16+ }
17+
18+ width := clamp(content.Cols, 0, base.Cols)
19+ height := clamp(content.Rows, 0, base.Rows)
20+ margins.Top = max(0, margins.Top)
21+ margins.Right = max(0, margins.Right)
22+ margins.Bottom = max(0, margins.Bottom)
23+ margins.Left = max(0, margins.Left)
24+
25+ x := (base.Cols - width) / 2
26+ y := (base.Rows - height) / 2
27+ switch anchor {
28+ case domain.AnchorTopLeft:
29+ x, y = margins.Left, margins.Top
30+ case domain.AnchorTop:
31+ y = margins.Top
32+ case domain.AnchorTopRight:
33+ x, y = base.Cols-margins.Right-width, margins.Top
34+ case domain.AnchorLeft:
35+ x = margins.Left
36+ case domain.AnchorRight:
37+ x = base.Cols - margins.Right - width
38+ case domain.AnchorBottomLeft:
39+ x, y = margins.Left, base.Rows-margins.Bottom-height
40+ case domain.AnchorBottom:
41+ y = base.Rows - margins.Bottom - height
42+ case domain.AnchorBottomRight:
43+ x, y = base.Cols-margins.Right-width, base.Rows-margins.Bottom-height
44+ case domain.AnchorCenter:
45+ // Center is the zero value and intentionally ignores all margins.
46+ default:
47+ // Invalid anchors fall back to center.
48+ }
49+
50+ return domain.Rect{
51+ X: clamp(x, 0, base.Cols-width),
52+ Y: clamp(y, 0, base.Rows-height),
53+ Width: width,
54+ Height: height,
55+ }
56+}
57+
58+func clamp(n, low, high int) int {
59+ if n < low {
60+ return low
61+ }
62+ if n > high {
63+ return high
64+ }
65+ return n
66+}
A internal/usecase/ui/place_test.go +108 -0
@@ -0,0 +1,108 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestPlaceAnchors(t *testing.T) {
10+ base := domain.Size{Cols: 31, Rows: 19}
11+ content := domain.Size{Cols: 8, Rows: 6}
12+ margins := Margins{Top: 2, Right: 3, Bottom: 4, Left: 5}
13+
14+ tests := []struct {
15+ anchor domain.Anchor
16+ want domain.Rect
17+ }{
18+ {domain.AnchorTopLeft, domain.Rect{X: 5, Y: 2, Width: 8, Height: 6}},
19+ {domain.AnchorTop, domain.Rect{X: 11, Y: 2, Width: 8, Height: 6}},
20+ {domain.AnchorTopRight, domain.Rect{X: 20, Y: 2, Width: 8, Height: 6}},
21+ {domain.AnchorLeft, domain.Rect{X: 5, Y: 6, Width: 8, Height: 6}},
22+ {domain.AnchorCenter, domain.Rect{X: 11, Y: 6, Width: 8, Height: 6}},
23+ {domain.AnchorRight, domain.Rect{X: 20, Y: 6, Width: 8, Height: 6}},
24+ {domain.AnchorBottomLeft, domain.Rect{X: 5, Y: 9, Width: 8, Height: 6}},
25+ {domain.AnchorBottom, domain.Rect{X: 11, Y: 9, Width: 8, Height: 6}},
26+ {domain.AnchorBottomRight, domain.Rect{X: 20, Y: 9, Width: 8, Height: 6}},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.anchor.String(), func(t *testing.T) {
30+ if got := Place(base, content, tt.anchor, margins); got != tt.want {
31+ t.Fatalf("Place() = %+v, want %+v", got, tt.want)
32+ }
33+ })
34+ }
35+}
36+
37+func TestPlaceNormalizesAndClamps(t *testing.T) {
38+ tests := []struct {
39+ name string
40+ base domain.Size
41+ content domain.Size
42+ anchor domain.Anchor
43+ margins Margins
44+ want domain.Rect
45+ }{
46+ {
47+ name: "center ignores margins and rounds down",
48+ base: domain.Size{Cols: 10, Rows: 8},
49+ content: domain.Size{Cols: 5, Rows: 3},
50+ anchor: domain.AnchorCenter,
51+ margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100},
52+ want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3},
53+ },
54+ {
55+ name: "invalid anchor falls back to center",
56+ base: domain.Size{Cols: 10, Rows: 8},
57+ content: domain.Size{Cols: 5, Rows: 3},
58+ anchor: domain.Anchor(99),
59+ margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100},
60+ want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3},
61+ },
62+ {
63+ name: "nonpositive base is empty",
64+ base: domain.Size{Cols: 0, Rows: 8},
65+ content: domain.Size{Cols: 5, Rows: 3},
66+ anchor: domain.AnchorTopLeft,
67+ want: domain.Rect{},
68+ },
69+ {
70+ name: "negative content clamps to zero",
71+ base: domain.Size{Cols: 10, Rows: 8},
72+ content: domain.Size{Cols: -5, Rows: -3},
73+ anchor: domain.AnchorBottomRight,
74+ want: domain.Rect{X: 10, Y: 8},
75+ },
76+ {
77+ name: "oversized content clamps to base",
78+ base: domain.Size{Cols: 10, Rows: 8},
79+ content: domain.Size{Cols: 50, Rows: 30},
80+ anchor: domain.AnchorTopLeft,
81+ margins: Margins{Top: 2, Left: 3},
82+ want: domain.Rect{Width: 10, Height: 8},
83+ },
84+ {
85+ name: "negative margins normalize to zero",
86+ base: domain.Size{Cols: 10, Rows: 8},
87+ content: domain.Size{Cols: 5, Rows: 3},
88+ anchor: domain.AnchorTopLeft,
89+ margins: Margins{Top: -2, Left: -3},
90+ want: domain.Rect{Width: 5, Height: 3},
91+ },
92+ {
93+ name: "excessive relevant margins clamp coordinates",
94+ base: domain.Size{Cols: 10, Rows: 8},
95+ content: domain.Size{Cols: 5, Rows: 3},
96+ anchor: domain.AnchorBottomRight,
97+ margins: Margins{Bottom: 20, Right: 20},
98+ want: domain.Rect{Width: 5, Height: 3},
99+ },
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ if got := Place(tt.base, tt.content, tt.anchor, tt.margins); got != tt.want {
104+ t.Fatalf("Place() = %+v, want %+v", got, tt.want)
105+ }
106+ })
107+ }
108+}
@@ -0,0 +1,108 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestPlaceAnchors(t *testing.T) {
10+ base := domain.Size{Cols: 31, Rows: 19}
11+ content := domain.Size{Cols: 8, Rows: 6}
12+ margins := Margins{Top: 2, Right: 3, Bottom: 4, Left: 5}
13+
14+ tests := []struct {
15+ anchor domain.Anchor
16+ want domain.Rect
17+ }{
18+ {domain.AnchorTopLeft, domain.Rect{X: 5, Y: 2, Width: 8, Height: 6}},
19+ {domain.AnchorTop, domain.Rect{X: 11, Y: 2, Width: 8, Height: 6}},
20+ {domain.AnchorTopRight, domain.Rect{X: 20, Y: 2, Width: 8, Height: 6}},
21+ {domain.AnchorLeft, domain.Rect{X: 5, Y: 6, Width: 8, Height: 6}},
22+ {domain.AnchorCenter, domain.Rect{X: 11, Y: 6, Width: 8, Height: 6}},
23+ {domain.AnchorRight, domain.Rect{X: 20, Y: 6, Width: 8, Height: 6}},
24+ {domain.AnchorBottomLeft, domain.Rect{X: 5, Y: 9, Width: 8, Height: 6}},
25+ {domain.AnchorBottom, domain.Rect{X: 11, Y: 9, Width: 8, Height: 6}},
26+ {domain.AnchorBottomRight, domain.Rect{X: 20, Y: 9, Width: 8, Height: 6}},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.anchor.String(), func(t *testing.T) {
30+ if got := Place(base, content, tt.anchor, margins); got != tt.want {
31+ t.Fatalf("Place() = %+v, want %+v", got, tt.want)
32+ }
33+ })
34+ }
35+}
36+
37+func TestPlaceNormalizesAndClamps(t *testing.T) {
38+ tests := []struct {
39+ name string
40+ base domain.Size
41+ content domain.Size
42+ anchor domain.Anchor
43+ margins Margins
44+ want domain.Rect
45+ }{
46+ {
47+ name: "center ignores margins and rounds down",
48+ base: domain.Size{Cols: 10, Rows: 8},
49+ content: domain.Size{Cols: 5, Rows: 3},
50+ anchor: domain.AnchorCenter,
51+ margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100},
52+ want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3},
53+ },
54+ {
55+ name: "invalid anchor falls back to center",
56+ base: domain.Size{Cols: 10, Rows: 8},
57+ content: domain.Size{Cols: 5, Rows: 3},
58+ anchor: domain.Anchor(99),
59+ margins: Margins{Top: 100, Right: 100, Bottom: 100, Left: 100},
60+ want: domain.Rect{X: 2, Y: 2, Width: 5, Height: 3},
61+ },
62+ {
63+ name: "nonpositive base is empty",
64+ base: domain.Size{Cols: 0, Rows: 8},
65+ content: domain.Size{Cols: 5, Rows: 3},
66+ anchor: domain.AnchorTopLeft,
67+ want: domain.Rect{},
68+ },
69+ {
70+ name: "negative content clamps to zero",
71+ base: domain.Size{Cols: 10, Rows: 8},
72+ content: domain.Size{Cols: -5, Rows: -3},
73+ anchor: domain.AnchorBottomRight,
74+ want: domain.Rect{X: 10, Y: 8},
75+ },
76+ {
77+ name: "oversized content clamps to base",
78+ base: domain.Size{Cols: 10, Rows: 8},
79+ content: domain.Size{Cols: 50, Rows: 30},
80+ anchor: domain.AnchorTopLeft,
81+ margins: Margins{Top: 2, Left: 3},
82+ want: domain.Rect{Width: 10, Height: 8},
83+ },
84+ {
85+ name: "negative margins normalize to zero",
86+ base: domain.Size{Cols: 10, Rows: 8},
87+ content: domain.Size{Cols: 5, Rows: 3},
88+ anchor: domain.AnchorTopLeft,
89+ margins: Margins{Top: -2, Left: -3},
90+ want: domain.Rect{Width: 5, Height: 3},
91+ },
92+ {
93+ name: "excessive relevant margins clamp coordinates",
94+ base: domain.Size{Cols: 10, Rows: 8},
95+ content: domain.Size{Cols: 5, Rows: 3},
96+ anchor: domain.AnchorBottomRight,
97+ margins: Margins{Bottom: 20, Right: 20},
98+ want: domain.Rect{Width: 5, Height: 3},
99+ },
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ if got := Place(tt.base, tt.content, tt.anchor, tt.margins); got != tt.want {
104+ t.Fatalf("Place() = %+v, want %+v", got, tt.want)
105+ }
106+ })
107+ }
108+}
M internal/usecase/ui/toast.go +21 -43
@@ -1,7 +1,6 @@
11 package ui
22
33 import (
4- "strconv"
54 "time"
65
76 "github.com/bnema/vev/internal/domain"
@@ -14,22 +13,11 @@ const (
1413 defaultToastMaxWidth = 60
1514 )
1615
17-// ToastAnchor describes where a toast is positioned within a frame.
18-type ToastAnchor int
19-
20-const (
21- ToastTopLeft ToastAnchor = iota
22- ToastTopRight
23- ToastBottomLeft
24- ToastBottomRight
25- ToastCenter
26-)
27-
2816 // Toast describes a transient message rendered over a frame.
2917 type Toast struct {
3018 ID string
3119 Message string
32- Anchor ToastAnchor
20+ Anchor domain.Anchor
3321 Duration time.Duration
3422 DimBackground bool
3523 MinWidth int
@@ -76,8 +64,8 @@ func (m *ToastManager) Show(now time.Time, toast Toast) {
7664 m.active[id] = ActiveToast{Toast: toast, ShownAt: now}
7765 }
7866
79-func toastAnchorID(anchor ToastAnchor) string {
80- return "anchor:" + strconv.Itoa(int(anchor))
67+func toastAnchorID(anchor domain.Anchor) string {
68+ return "anchor:" + anchor.String()
8169 }
8270
8371 // Dismiss removes a toast by ID.
@@ -136,32 +124,12 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect {
136124 height := 1 + toast.PaddingY*2 + borderHeight
137125 height = clamp(height, 0, base.Rows)
138126
139- x := 0
140- y := 0
141- switch toast.Anchor {
142- case ToastTopLeft:
143- x = defaultToastMargin
144- y = defaultToastMargin
145- case ToastTopRight:
146- x = base.Cols - defaultToastMargin - width
147- y = defaultToastMargin
148- case ToastBottomLeft:
149- x = defaultToastMargin
150- y = base.Rows - defaultToastMargin - height
151- case ToastBottomRight:
152- x = base.Cols - defaultToastMargin - width
153- y = base.Rows - defaultToastMargin - height
154- case ToastCenter:
155- x = (base.Cols - width) / 2
156- y = (base.Rows - height) / 2
157- }
158-
159- return domain.Rect{
160- X: clamp(x, 0, max(0, base.Cols-width)),
161- Y: clamp(y, 0, max(0, base.Rows-height)),
162- Width: width,
163- Height: height,
164- }
127+ return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{
128+ Top: defaultToastMargin,
129+ Right: defaultToastMargin,
130+ Bottom: defaultToastMargin,
131+ Left: defaultToastMargin,
132+ })
165133 }
166134
167135 // CompositeToasts draws the latest toast per anchor over frame.
@@ -196,14 +164,24 @@ func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastSty
196164 }
197165
198166 func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast {
199- byAnchor := make(map[ToastAnchor]ActiveToast)
167+ byAnchor := make(map[domain.Anchor]ActiveToast)
200168 for _, toast := range toasts {
201169 current, ok := byAnchor[toast.Anchor]
202170 if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) {
203171 byAnchor[toast.Anchor] = toast
204172 }
205173 }
206- anchors := []ToastAnchor{ToastTopLeft, ToastTopRight, ToastBottomLeft, ToastBottomRight, ToastCenter}
174+ anchors := []domain.Anchor{
175+ domain.AnchorTopLeft,
176+ domain.AnchorTopRight,
177+ domain.AnchorBottomLeft,
178+ domain.AnchorBottomRight,
179+ domain.AnchorCenter,
180+ domain.AnchorTop,
181+ domain.AnchorLeft,
182+ domain.AnchorRight,
183+ domain.AnchorBottom,
184+ }
207185 visible := make([]ActiveToast, 0, len(byAnchor))
208186 for _, anchor := range anchors {
209187 if toast, ok := byAnchor[anchor]; ok {
@@ -1,7 +1,6 @@
1 package ui1 package ui
2 2
3 import (3 import (
4- "strconv"
5 "time"4 "time"
6 5
7 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
@@ -14,22 +13,11 @@ const (
14 defaultToastMaxWidth = 6013 defaultToastMaxWidth = 60
15 )14 )
16 15
17-// ToastAnchor describes where a toast is positioned within a frame.
18-type ToastAnchor int
19-
20-const (
21- ToastTopLeft ToastAnchor = iota
22- ToastTopRight
23- ToastBottomLeft
24- ToastBottomRight
25- ToastCenter
26-)
27-
28 // Toast describes a transient message rendered over a frame.16 // Toast describes a transient message rendered over a frame.
29 type Toast struct {17 type Toast struct {
30 ID string18 ID string
31 Message string19 Message string
32- Anchor ToastAnchor20+ Anchor domain.Anchor
33 Duration time.Duration21 Duration time.Duration
34 DimBackground bool22 DimBackground bool
35 MinWidth int23 MinWidth int
@@ -76,8 +64,8 @@ func (m *ToastManager) Show(now time.Time, toast Toast) {
76 m.active[id] = ActiveToast{Toast: toast, ShownAt: now}64 m.active[id] = ActiveToast{Toast: toast, ShownAt: now}
77 }65 }
78 66
79-func toastAnchorID(anchor ToastAnchor) string {67+func toastAnchorID(anchor domain.Anchor) string {
80- return "anchor:" + strconv.Itoa(int(anchor))68+ return "anchor:" + anchor.String()
81 }69 }
82 70
83 // Dismiss removes a toast by ID.71 // Dismiss removes a toast by ID.
@@ -136,32 +124,12 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect {
136 height := 1 + toast.PaddingY*2 + borderHeight124 height := 1 + toast.PaddingY*2 + borderHeight
137 height = clamp(height, 0, base.Rows)125 height = clamp(height, 0, base.Rows)
138 126
139- x := 0127+ return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{
140- y := 0128+ Top: defaultToastMargin,
141- switch toast.Anchor {129+ Right: defaultToastMargin,
142- case ToastTopLeft:130+ Bottom: defaultToastMargin,
143- x = defaultToastMargin131+ Left: defaultToastMargin,
144- y = defaultToastMargin132+ })
145- case ToastTopRight:
146- x = base.Cols - defaultToastMargin - width
147- y = defaultToastMargin
148- case ToastBottomLeft:
149- x = defaultToastMargin
150- y = base.Rows - defaultToastMargin - height
151- case ToastBottomRight:
152- x = base.Cols - defaultToastMargin - width
153- y = base.Rows - defaultToastMargin - height
154- case ToastCenter:
155- x = (base.Cols - width) / 2
156- y = (base.Rows - height) / 2
157- }
158-
159- return domain.Rect{
160- X: clamp(x, 0, max(0, base.Cols-width)),
161- Y: clamp(y, 0, max(0, base.Rows-height)),
162- Width: width,
163- Height: height,
164- }
165 }133 }
166 134
167 // CompositeToasts draws the latest toast per anchor over frame.135 // CompositeToasts draws the latest toast per anchor over frame.
@@ -196,14 +164,24 @@ func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastSty
196 }164 }
197 165
198 func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast {166 func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast {
199- byAnchor := make(map[ToastAnchor]ActiveToast)167+ byAnchor := make(map[domain.Anchor]ActiveToast)
200 for _, toast := range toasts {168 for _, toast := range toasts {
201 current, ok := byAnchor[toast.Anchor]169 current, ok := byAnchor[toast.Anchor]
202 if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) {170 if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) {
203 byAnchor[toast.Anchor] = toast171 byAnchor[toast.Anchor] = toast
204 }172 }
205 }173 }
206- anchors := []ToastAnchor{ToastTopLeft, ToastTopRight, ToastBottomLeft, ToastBottomRight, ToastCenter}174+ anchors := []domain.Anchor{
175+ domain.AnchorTopLeft,
176+ domain.AnchorTopRight,
177+ domain.AnchorBottomLeft,
178+ domain.AnchorBottomRight,
179+ domain.AnchorCenter,
180+ domain.AnchorTop,
181+ domain.AnchorLeft,
182+ domain.AnchorRight,
183+ domain.AnchorBottom,
184+ }
207 visible := make([]ActiveToast, 0, len(byAnchor))185 visible := make([]ActiveToast, 0, len(byAnchor))
208 for _, anchor := range anchors {186 for _, anchor := range anchors {
209 if toast, ok := byAnchor[anchor]; ok {187 if toast, ok := byAnchor[anchor]; ok {
M internal/usecase/ui/toast_test.go +67 -69
@@ -54,13 +54,19 @@ func TestToastManagerDurationDismissAndClear(t *testing.T) {
5454 }
5555 })
5656
57- t.Run("empty IDs replace per anchor", func(t *testing.T) {
58- m := NewToastManager()
59- m.Show(now, Toast{Message: "first", Anchor: ToastCenter})
60- m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: ToastCenter})
61- active := m.Active(now.Add(time.Second))
62- if len(active) != 1 || active[0].Message != "second" {
63- t.Fatalf("Active() after same-anchor replacement = %+v, want only second toast", active)
57+ t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) {
58+ anchors := []domain.Anchor{
59+ domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight,
60+ domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight,
61+ }
62+ for _, anchor := range anchors {
63+ m := NewToastManager()
64+ m.Show(now, Toast{Message: "first", Anchor: anchor})
65+ m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor})
66+ active := m.Active(now.Add(time.Second))
67+ if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() {
68+ t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active)
69+ }
6470 }
6571 })
6672
@@ -83,64 +89,29 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
8389 }
8490
8591 tests := []struct {
86- name string
87- base domain.Size
88- make func() Toast
89- want domain.Rect
92+ name string
93+ base domain.Size
94+ toast Toast
95+ want domain.Rect
9096 }{
91- {
92- name: "center",
93- base: domain.Size{Cols: 80, Rows: 24},
94- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastCenter} },
95- want: domain.Rect{X: 27, Y: 10, Width: 26, Height: 3},
96- },
97- {
98- name: "top left has two cell margins",
99- base: domain.Size{Cols: 80, Rows: 24},
100- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopLeft} },
101- want: domain.Rect{X: 2, Y: 2, Width: 26, Height: 3},
102- },
103- {
104- name: "top right has two cell margins",
105- base: domain.Size{Cols: 80, Rows: 24},
106- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopRight} },
107- want: domain.Rect{X: 52, Y: 2, Width: 26, Height: 3},
108- },
109- {
110- name: "bottom left has two cell margins",
111- base: domain.Size{Cols: 80, Rows: 24},
112- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomLeft} },
113- want: domain.Rect{X: 2, Y: 19, Width: 26, Height: 3},
114- },
115- {
116- name: "bottom right has two cell margins",
117- base: domain.Size{Cols: 80, Rows: 24},
118- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomRight} },
119- want: domain.Rect{X: 52, Y: 19, Width: 26, Height: 3},
120- },
121- {
122- name: "zero frame",
123- base: domain.Size{},
124- make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
125- want: domain.Rect{},
126- },
127- {
128- name: "one by one clamps",
129- base: domain.Size{Cols: 1, Rows: 1},
130- make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
131- want: domain.Rect{X: 0, Y: 0, Width: 1, Height: 1},
132- },
133- {
134- name: "narrow frame clamps width and margin",
135- base: domain.Size{Cols: 8, Rows: 2},
136- make: func() Toast { return Toast{Message: "long message", Anchor: ToastBottomRight} },
137- want: domain.Rect{X: 0, Y: 0, Width: 8, Height: 2},
138- },
97+ {"top left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopLeft}, domain.Rect{X: 2, Y: 2, Width: 26, Height: 3}},
98+ {"top", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTop}, domain.Rect{X: 27, Y: 2, Width: 26, Height: 3}},
99+ {"top right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopRight}, domain.Rect{X: 52, Y: 2, Width: 26, Height: 3}},
100+ {"left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorLeft}, domain.Rect{X: 2, Y: 10, Width: 26, Height: 3}},
101+ {"center", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorCenter}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
102+ {"right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorRight}, domain.Rect{X: 52, Y: 10, Width: 26, Height: 3}},
103+ {"bottom left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomLeft}, domain.Rect{X: 2, Y: 19, Width: 26, Height: 3}},
104+ {"bottom", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottom}, domain.Rect{X: 27, Y: 19, Width: 26, Height: 3}},
105+ {"bottom right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomRight}, domain.Rect{X: 52, Y: 19, Width: 26, Height: 3}},
106+ {"omitted anchor centers", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
107+ {"zero frame", domain.Size{}, Toast{Message: "tiny"}, domain.Rect{}},
108+ {"one by one clamps", domain.Size{Cols: 1, Rows: 1}, Toast{Message: "tiny"}, domain.Rect{Width: 1, Height: 1}},
109+ {"narrow frame clamps width and margin", domain.Size{Cols: 8, Rows: 2}, Toast{Message: "long message", Anchor: domain.AnchorBottomRight}, domain.Rect{Width: 8, Height: 2}},
139110 }
140111
141112 for _, tt := range tests {
142113 t.Run(tt.name, func(t *testing.T) {
143- if got := ToastBounds(tt.base, tt.make()); got != tt.want {
114+ if got := ToastBounds(tt.base, tt.toast); got != tt.want {
144115 t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)
145116 }
146117 })
@@ -155,8 +126,8 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
155126 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
156127 FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
157128
158- CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: ToastCenter}}}, styles, nil)
159- bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: ToastCenter})
129+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil)
130+ bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
160131 if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
161132 t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
162133 }
@@ -174,7 +145,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
174145
175146 t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
176147 f := renderer.NewFrame(8, 3)
177- CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: ToastCenter, MaxWidth: 8}}}, styles, nil)
148+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil)
178149 assertRune(t, f, 0, 0, '┌')
179150 assertRune(t, f, 7, 0, '┐')
180151 assertRune(t, f, 2, 1, 'a')
@@ -190,9 +161,9 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
190161 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}
191162
192163 CompositeToasts(f, []ActiveToast{
193- {Toast: Toast{ID: "old", Message: "Old", Anchor: ToastTopLeft}, ShownAt: now},
194- {Toast: Toast{ID: "new", Message: "New", Anchor: ToastTopLeft}, ShownAt: now.Add(time.Second)},
195- {Toast: Toast{ID: "center", Message: "Mid", Anchor: ToastCenter}, ShownAt: now},
164+ {Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now},
165+ {Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)},
166+ {Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now},
196167 }, styles, nil)
197168
198169 assertRune(t, f, 4, 3, 'N')
@@ -207,6 +178,33 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
207178 }
208179 }
209180
181+func TestLatestToastsByAnchorTieAndOrder(t *testing.T) {
182+ now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
183+ anchors := []domain.Anchor{
184+ domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft,
185+ domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop,
186+ domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom,
187+ }
188+ toasts := make([]ActiveToast, 0, len(anchors)+1)
189+ for _, anchor := range anchors {
190+ toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now})
191+ }
192+ toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now})
193+
194+ visible := latestToastsByAnchor(toasts)
195+ if len(visible) != len(anchors) {
196+ t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors))
197+ }
198+ for i, anchor := range anchors {
199+ if visible[i].Anchor != anchor {
200+ t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor)
201+ }
202+ }
203+ if visible[0].ID != "later-input" {
204+ t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID)
205+ }
206+}
207+
210208 func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
211209 f := renderer.NewFrame(14, 5)
212210 baseStyle := renderer.Style{Foreground: 1, Background: 2}
@@ -218,7 +216,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
218216 }
219217 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
220218
221- CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: ToastCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {
219+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {
222220 return dimStyle
223221 })
224222
@@ -229,7 +227,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
229227 if !outside.Style.Equal(dimStyle) {
230228 t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)
231229 }
232- bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: ToastCenter})
230+ bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
233231 boxCell := f.At(bounds.X, bounds.Y)
234232 if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {
235233 t.Fatalf("box cell = %+v, want toast box style", boxCell)
@@ -245,7 +243,7 @@ func TestCompositeToastsHandlesTinyFrames(t *testing.T) {
245243 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
246244 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
247245 f := renderer.NewFrame(size.Cols, size.Rows)
248- CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: ToastCenter}}}, styles, nil)
246+ CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil)
249247 })
250248 }
251249 }
@@ -54,13 +54,19 @@ func TestToastManagerDurationDismissAndClear(t *testing.T) {
54 }54 }
55 })55 })
56 56
57- t.Run("empty IDs replace per anchor", func(t *testing.T) {57+ t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) {
58- m := NewToastManager()58+ anchors := []domain.Anchor{
59- m.Show(now, Toast{Message: "first", Anchor: ToastCenter})59+ domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight,
60- m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: ToastCenter})60+ domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight,
61- active := m.Active(now.Add(time.Second))61+ }
62- if len(active) != 1 || active[0].Message != "second" {62+ for _, anchor := range anchors {
63- t.Fatalf("Active() after same-anchor replacement = %+v, want only second toast", active)63+ m := NewToastManager()
64+ m.Show(now, Toast{Message: "first", Anchor: anchor})
65+ m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor})
66+ active := m.Active(now.Add(time.Second))
67+ if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() {
68+ t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active)
69+ }
64 }70 }
65 })71 })
66 72
@@ -83,64 +89,29 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
83 }89 }
84 90
85 tests := []struct {91 tests := []struct {
86- name string92+ name string
87- base domain.Size93+ base domain.Size
88- make func() Toast94+ toast Toast
89- want domain.Rect95+ want domain.Rect
90 }{96 }{
91- {97+ {"top left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopLeft}, domain.Rect{X: 2, Y: 2, Width: 26, Height: 3}},
92- name: "center",98+ {"top", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTop}, domain.Rect{X: 27, Y: 2, Width: 26, Height: 3}},
93- base: domain.Size{Cols: 80, Rows: 24},99+ {"top right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopRight}, domain.Rect{X: 52, Y: 2, Width: 26, Height: 3}},
94- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastCenter} },100+ {"left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorLeft}, domain.Rect{X: 2, Y: 10, Width: 26, Height: 3}},
95- want: domain.Rect{X: 27, Y: 10, Width: 26, Height: 3},101+ {"center", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorCenter}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
96- },102+ {"right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorRight}, domain.Rect{X: 52, Y: 10, Width: 26, Height: 3}},
97- {103+ {"bottom left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomLeft}, domain.Rect{X: 2, Y: 19, Width: 26, Height: 3}},
98- name: "top left has two cell margins",104+ {"bottom", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottom}, domain.Rect{X: 27, Y: 19, Width: 26, Height: 3}},
99- base: domain.Size{Cols: 80, Rows: 24},105+ {"bottom right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomRight}, domain.Rect{X: 52, Y: 19, Width: 26, Height: 3}},
100- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopLeft} },106+ {"omitted anchor centers", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
101- want: domain.Rect{X: 2, Y: 2, Width: 26, Height: 3},107+ {"zero frame", domain.Size{}, Toast{Message: "tiny"}, domain.Rect{}},
102- },108+ {"one by one clamps", domain.Size{Cols: 1, Rows: 1}, Toast{Message: "tiny"}, domain.Rect{Width: 1, Height: 1}},
103- {109+ {"narrow frame clamps width and margin", domain.Size{Cols: 8, Rows: 2}, Toast{Message: "long message", Anchor: domain.AnchorBottomRight}, domain.Rect{Width: 8, Height: 2}},
104- name: "top right has two cell margins",
105- base: domain.Size{Cols: 80, Rows: 24},
106- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopRight} },
107- want: domain.Rect{X: 52, Y: 2, Width: 26, Height: 3},
108- },
109- {
110- name: "bottom left has two cell margins",
111- base: domain.Size{Cols: 80, Rows: 24},
112- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomLeft} },
113- want: domain.Rect{X: 2, Y: 19, Width: 26, Height: 3},
114- },
115- {
116- name: "bottom right has two cell margins",
117- base: domain.Size{Cols: 80, Rows: 24},
118- make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomRight} },
119- want: domain.Rect{X: 52, Y: 19, Width: 26, Height: 3},
120- },
121- {
122- name: "zero frame",
123- base: domain.Size{},
124- make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
125- want: domain.Rect{},
126- },
127- {
128- name: "one by one clamps",
129- base: domain.Size{Cols: 1, Rows: 1},
130- make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
131- want: domain.Rect{X: 0, Y: 0, Width: 1, Height: 1},
132- },
133- {
134- name: "narrow frame clamps width and margin",
135- base: domain.Size{Cols: 8, Rows: 2},
136- make: func() Toast { return Toast{Message: "long message", Anchor: ToastBottomRight} },
137- want: domain.Rect{X: 0, Y: 0, Width: 8, Height: 2},
138- },
139 }110 }
140 111
141 for _, tt := range tests {112 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {113 t.Run(tt.name, func(t *testing.T) {
143- if got := ToastBounds(tt.base, tt.make()); got != tt.want {114+ if got := ToastBounds(tt.base, tt.toast); got != tt.want {
144 t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)115 t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)
145 }116 }
146 })117 })
@@ -155,8 +126,8 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
155 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}126 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
156 FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)127 FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
157 128
158- CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: ToastCenter}}}, styles, nil)129+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil)
159- bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: ToastCenter})130+ bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
160 if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {131 if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
161 t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)132 t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
162 }133 }
@@ -174,7 +145,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
174 145
175 t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {146 t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
176 f := renderer.NewFrame(8, 3)147 f := renderer.NewFrame(8, 3)
177- CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: ToastCenter, MaxWidth: 8}}}, styles, nil)148+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil)
178 assertRune(t, f, 0, 0, '┌')149 assertRune(t, f, 0, 0, '┌')
179 assertRune(t, f, 7, 0, '┐')150 assertRune(t, f, 7, 0, '┐')
180 assertRune(t, f, 2, 1, 'a')151 assertRune(t, f, 2, 1, 'a')
@@ -190,9 +161,9 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
190 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}161 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}
191 162
192 CompositeToasts(f, []ActiveToast{163 CompositeToasts(f, []ActiveToast{
193- {Toast: Toast{ID: "old", Message: "Old", Anchor: ToastTopLeft}, ShownAt: now},164+ {Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now},
194- {Toast: Toast{ID: "new", Message: "New", Anchor: ToastTopLeft}, ShownAt: now.Add(time.Second)},165+ {Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)},
195- {Toast: Toast{ID: "center", Message: "Mid", Anchor: ToastCenter}, ShownAt: now},166+ {Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now},
196 }, styles, nil)167 }, styles, nil)
197 168
198 assertRune(t, f, 4, 3, 'N')169 assertRune(t, f, 4, 3, 'N')
@@ -207,6 +178,33 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
207 }178 }
208 }179 }
209 180
181+func TestLatestToastsByAnchorTieAndOrder(t *testing.T) {
182+ now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
183+ anchors := []domain.Anchor{
184+ domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft,
185+ domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop,
186+ domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom,
187+ }
188+ toasts := make([]ActiveToast, 0, len(anchors)+1)
189+ for _, anchor := range anchors {
190+ toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now})
191+ }
192+ toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now})
193+
194+ visible := latestToastsByAnchor(toasts)
195+ if len(visible) != len(anchors) {
196+ t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors))
197+ }
198+ for i, anchor := range anchors {
199+ if visible[i].Anchor != anchor {
200+ t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor)
201+ }
202+ }
203+ if visible[0].ID != "later-input" {
204+ t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID)
205+ }
206+}
207+
210 func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {208 func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
211 f := renderer.NewFrame(14, 5)209 f := renderer.NewFrame(14, 5)
212 baseStyle := renderer.Style{Foreground: 1, Background: 2}210 baseStyle := renderer.Style{Foreground: 1, Background: 2}
@@ -218,7 +216,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
218 }216 }
219 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}217 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
220 218
221- CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: ToastCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {219+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {
222 return dimStyle220 return dimStyle
223 })221 })
224 222
@@ -229,7 +227,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
229 if !outside.Style.Equal(dimStyle) {227 if !outside.Style.Equal(dimStyle) {
230 t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)228 t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)
231 }229 }
232- bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: ToastCenter})230+ bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
233 boxCell := f.At(bounds.X, bounds.Y)231 boxCell := f.At(bounds.X, bounds.Y)
234 if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {232 if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {
235 t.Fatalf("box cell = %+v, want toast box style", boxCell)233 t.Fatalf("box cell = %+v, want toast box style", boxCell)
@@ -245,7 +243,7 @@ func TestCompositeToastsHandlesTinyFrames(t *testing.T) {
245 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {243 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
246 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {244 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
247 f := renderer.NewFrame(size.Cols, size.Rows)245 f := renderer.NewFrame(size.Cols, size.Rows)
248- CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: ToastCenter}}}, styles, nil)246+ CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil)
249 })247 })
250 }248 }
251 }249 }

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 2

Tags 13

↑↓ moveenter openesc close