Merge pull request #68 from bnema/feature/unified-ui-anchoring
refactor(ui): unify overlay anchoring
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 | |||
| 49 | 49 | # Theme: auto follows the client; dark/light force built-in palettes. | |
| 50 | 50 | 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 | 56 | # One prewarmed floating terminal per tab. An empty command uses the normal shell. | |
| 53 | 57 | floating.command = | |
| 54 | 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 | 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 | 125 | ### Bar right anchors | |
| 118 | 126 | ||
| 119 | 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. | |
| @@ -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 = auto | 50 | 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 anchors | 125 | ### 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) { | |||
| 31 | 31 | var warnings []domain.Warning | |
| 32 | 32 | seenBindingKeys := make(map[string]bool) | |
| 33 | 33 | seenFloatingKeys := make(map[string]bool) | |
| 34 | + | seenPaletteKeys := make(map[string]bool) | |
| 34 | 35 | ||
| 35 | 36 | scanner := bufio.NewScanner(r) | |
| 36 | 37 | lineNo := 0 | |
| @@ -90,8 +91,20 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 90 | 91 | interval = domain.MinBarInterval | |
| 91 | 92 | } | |
| 92 | 93 | 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 | 106 | case key == "floating.command": | |
| 94 | - | warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo) | |
| 107 | + | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | |
| 95 | 108 | command, ok := parseBarCommand(value) | |
| 96 | 109 | if !ok { | |
| 97 | 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 | 113 | cfg.Floating.Command = command | |
| 101 | 114 | case key == "floating.width": | |
| 102 | - | warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo) | |
| 115 | + | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | |
| 103 | 116 | width, ok := parsePercentage(value) | |
| 104 | 117 | if !ok { | |
| 105 | 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 | 121 | cfg.Floating.Width = width | |
| 109 | 122 | case key == "floating.height": | |
| 110 | - | warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo) | |
| 123 | + | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | |
| 111 | 124 | height, ok := parsePercentage(value) | |
| 112 | 125 | if !ok { | |
| 113 | 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 | 230 | 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 | 234 | if seen[key] { | |
| 222 | 235 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| 223 | 236 | } | |
| @@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 31 | var warnings []domain.Warning | 31 | 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 := 0 | 37 | lineNo := 0 | ||
| @@ -90,8 +91,20 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 90 | interval = domain.MinBarInterval | 91 | interval = domain.MinBarInterval | ||
| 91 | } | 92 | } | ||
| 92 | cfg.Bar.Interval = interval | 93 | 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 = command | 113 | 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 = width | 121 | 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}, nil | 230 | 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 { | |||
| 47 | 47 | 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 | 57 | // FloatingConfig contains launch and sizing settings for per-tab floating panes. | |
| 51 | 58 | type FloatingConfig struct { | |
| 52 | 59 | Command string | |
| @@ -63,6 +70,7 @@ type Config struct { | |||
| 63 | 70 | BindingEntries []ConfigEntry | |
| 64 | 71 | Codes map[string]string | |
| 65 | 72 | Snapshot SnapshotConfig | |
| 73 | + | Palette PaletteConfig | |
| 66 | 74 | Floating FloatingConfig | |
| 67 | 75 | } | |
| 68 | 76 | ||
| @@ -47,6 +47,13 @@ type BarConfig struct { | |||||
| 47 | Interval time.Duration | 47 | 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 string | 59 | Command string | ||
| @@ -63,6 +70,7 @@ type Config struct { | |||||
| 63 | BindingEntries []ConfigEntry | 70 | BindingEntries []ConfigEntry | ||
| 64 | Codes map[string]string | 71 | Codes map[string]string | ||
| 65 | Snapshot SnapshotConfig | 72 | Snapshot SnapshotConfig | ||
| 73 | + | Palette PaletteConfig | |||
| 66 | Floating FloatingConfig | 74 | Floating FloatingConfig | ||
| 67 | } | 75 | } | ||
| 68 | 76 | ||||
M internal/domain/types.go +74 -0
| @@ -1,5 +1,79 @@ | |||
| 1 | 1 | 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 | 77 | // Size is a terminal dimension in columns and rows. | |
| 4 | 78 | type Size struct { | |
| 5 | 79 | Cols, Rows int | |
| @@ -1,5 +1,79 @@ | |||||
| 1 | package domain | 1 | 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 int | 79 | Cols, Rows int | ||
M internal/domain/types_test.go +65 -0
| @@ -2,6 +2,71 @@ package domain | |||
| 2 | 2 | ||
| 3 | 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 | 70 | func TestSizeValid(t *testing.T) { | |
| 6 | 71 | tests := []struct { | |
| 7 | 72 | 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 string | 72 | name string | ||
M internal/usecase/client/reconnect_toast.go +2 -2
| @@ -58,7 +58,7 @@ func reconnectToastBounds(size domain.Size) domain.Rect { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 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 | 64 | func reconnectToastLines(bounds domain.Rect) []string { | |
| @@ -70,7 +70,7 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |||
| 70 | 70 | return nil | |
| 71 | 71 | } | |
| 72 | 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 | 74 | Text: renderer.DefaultStyle(), | |
| 75 | 75 | Box: renderer.DefaultStyle(), | |
| 76 | 76 | }, 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 nil | 70 | 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) { | |||
| 245 | 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 | 253 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| 249 | 254 | var out bytes.Buffer | |
| 250 | 255 | 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.Buffer | 254 | 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) { | |||
| 29 | 29 | d.restoreProcessAllowlist.Store(&allowlist) | |
| 30 | 30 | floating := cfg.Floating | |
| 31 | 31 | d.floatingConfig.Store(&floating) | |
| 32 | + | palette := cfg.Palette | |
| 33 | + | d.paletteConfig.Store(&palette) | |
| 32 | 34 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 33 | 35 | if d.barScripts != nil { | |
| 34 | 36 | d.barScripts.mu.Lock() | |
| @@ -178,6 +180,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) { | |||
| 178 | 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 | 190 | func (d *Daemon) currentFloatingConfig() domain.FloatingConfig { | |
| 182 | 191 | if cfg := d.floatingConfig.Load(); cfg != nil { | |
| 183 | 192 | 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.Floating | 30 | 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 *cfg | 192 | return *cfg | ||
M internal/usecase/daemon/config_test.go +52 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -49,6 +50,57 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 49 | 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 | 104 | func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | |
| 53 | 105 | d := newTestDaemon(t, nil, stubClock{}) | |
| 54 | 106 | 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 ( | |||
| 37 | 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 | 42 | func copyTargetPane(rt *overlayRuntime) *pane { | |
| 43 | 43 | 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 ( | |||
| 18 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 20 | 20 | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 21 | 22 | "github.com/bnema/vev/pkg/renderer" | |
| 22 | 23 | "github.com/bnema/vev/pkg/vt" | |
| 23 | 24 | ) | |
| @@ -96,6 +97,14 @@ func (t *ownedSwapErrorTransport) Close() error { return nil } | |||
| 96 | 97 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 97 | 98 | // 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 | 108 | func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |
| 100 | 109 | p, release := newBlockingPTY(t) | |
| 101 | 110 | 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 it | 97 | // 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 by | 98 | // 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 { | |||
| 136 | 136 | codeOverrides atomic.Pointer[map[string]string] | |
| 137 | 137 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 138 | 138 | floatingConfig atomic.Pointer[domain.FloatingConfig] | |
| 139 | + | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 139 | 140 | themeMode atomic.Uint32 | |
| 140 | 141 | barScripts *barScriptState | |
| 141 | 142 | resumeParkGrace time.Duration | |
| @@ -316,8 +317,11 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 316 | 317 | running: make(map[domain.SessionID]bool), | |
| 317 | 318 | }, | |
| 318 | 319 | } | |
| 319 | - | defaultFloating := domain.Defaults().Floating | |
| 320 | + | defaults := domain.Defaults() | |
| 321 | + | defaultFloating := defaults.Floating | |
| 320 | 322 | d.floatingConfig.Store(&defaultFloating) | |
| 323 | + | defaultPalette := defaults.Palette | |
| 324 | + | d.paletteConfig.Store(&defaultPalette) | |
| 321 | 325 | for _, o := range opts { | |
| 322 | 326 | o(d) | |
| 323 | 327 | } | |
| @@ -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.Uint32 | 140 | themeMode atomic.Uint32 | ||
| 140 | barScripts *barScriptState | 141 | barScripts *barScriptState | ||
| 141 | resumeParkGrace time.Duration | 142 | 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().Floating | 320 | + | 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 ( | |||
| 14 | 14 | 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 | 20 | modal := paletteModal | |
| 21 | 21 | if size.Cols >= paletteRailBreakpoint { | |
| 22 | 22 | 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 | |
| 25 | 28 | } | |
| 26 | 29 | return modal | |
| 27 | 30 | } | |
| @@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 298 | 301 | 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 | 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 | 307 | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 305 | 308 | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | |
| 306 | 309 | }) | |
| @@ -14,14 +14,17 @@ const ( | |||||
| 14 | paletteRailWidth = 64 | 14 | 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 := paletteModal | 20 | modal := paletteModal | ||
| 21 | if size.Cols >= paletteRailBreakpoint { | 21 | if size.Cols >= paletteRailBreakpoint { | ||
| 22 | modal.FixedWidth = paletteRailWidth | 22 | modal.FixedWidth = paletteRailWidth | ||
| 23 | - | modal.HorizontalAnchor = ui.HorizontalAnchorRight | 23 | + | } |
| 24 | - | modal.RightMargin = 1 | 24 | + | if cfg.AnchorSet { |
| 25 | + | modal.Anchor = cfg.Anchor | |||
| 26 | + | } else if size.Cols >= paletteRailBreakpoint { | |||
| 27 | + | modal.Anchor = domain.AnchorBottomRight | |||
| 25 | } | 28 | } | ||
| 26 | return modal | 29 | return modal | ||
| 27 | } | 30 | } | ||
| @@ -298,9 +301,9 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 298 | return nil | 301 | 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 ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| 17 | 19 | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |
| @@ -241,25 +243,54 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||
| 241 | 243 | } | |
| 242 | 244 | ||
| 243 | 245 | func TestPaletteModalGeometry(t *testing.T) { | |
| 244 | - | tests := []struct { | |
| 246 | + | type testCase struct { | |
| 245 | 247 | name string | |
| 246 | 248 | size domain.Size | |
| 249 | + | cfg domain.PaletteConfig | |
| 247 | 250 | 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}}) | |
| 253 | 267 | } | |
| 254 | 268 | ||
| 255 | 269 | for _, tt := range tests { | |
| 256 | 270 | t.Run(tt.name, func(t *testing.T) { | |
| 257 | - | modal := paletteModalFor(tt.size) | |
| 271 | + | modal := paletteModalFor(tt.size, tt.cfg) | |
| 258 | 272 | require.Equal(t, tt.want, modal.Bounds(tt.size)) | |
| 259 | 273 | require.Equal(t, " Commands ", modal.Title) | |
| 260 | 274 | 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) | |
| 263 | 294 | }) | |
| 264 | 295 | } | |
| 265 | 296 | } | |
| @@ -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 string | 247 | name string | ||
| 246 | size domain.Size | 248 | size domain.Size | ||
| 249 | + | cfg domain.PaletteConfig | |||
| 247 | want domain.Rect | 250 | 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 ( | |||
| 37 | 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 | 42 | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |
| 43 | 43 | 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) { | |||
| 516 | 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 | 526 | func TestPickerResizeRecomposesModal(t *testing.T) { | |
| 520 | 527 | p, releasePTY := newBlockingPTY(t) | |
| 521 | 528 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 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 | 16 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 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 | 18 | func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | |
| 12 | 19 | p, release := newBlockingPTY(t) | |
| 13 | 20 | 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) { | |||
| 350 | 350 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 351 | 351 | ||
| 352 | 352 | styles := newThemeStyles(ac.getTheme()) | |
| 353 | + | paletteCfg := d.currentPaletteConfig() | |
| 353 | 354 | tb.mu.Lock() | |
| 354 | 355 | layoutSnap := solveTabLayoutLocked(tb) | |
| 355 | 356 | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | |
| @@ -429,7 +430,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 429 | 430 | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | |
| 430 | 431 | } | |
| 431 | 432 | if overlays.paletteActive { | |
| 432 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles) | |
| 433 | + | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles) | |
| 433 | 434 | } | |
| 434 | 435 | if overlays.promptActive { | |
| 435 | 436 | 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 ( | |||
| 5 | 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 | 8 | // Modal describes a rectangular overlay. | |
| 29 | 9 | 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 | |
| 41 | 19 | } | |
| 42 | 20 | ||
| 43 | 21 | // Bounds returns the modal rectangle positioned within base and clamped to base. | |
| 44 | 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 | 23 | width := percentOf(base.Cols, m.WidthPct) | |
| 50 | 24 | height := percentOf(base.Rows, m.HeightPct) | |
| 51 | 25 | if m.FixedWidth > 0 { | |
| @@ -60,24 +34,7 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 60 | 34 | if height < m.MinHeight { | |
| 61 | 35 | height = m.MinHeight | |
| 62 | 36 | } | |
| 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) | |
| 81 | 38 | } | |
| 82 | 39 | ||
| 83 | 40 | // Inner returns the modal content rectangle after removing a one-cell border. | |
| @@ -193,16 +150,6 @@ func percentOf(n, pct int) int { | |||
| 193 | 150 | 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 | 153 | func textWidth(text string) int { | |
| 207 | 154 | width := 0 | |
| 208 | 155 | 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 int | 10 | + | WidthPct int |
| 31 | - | HeightPct int | 11 | + | HeightPct int |
| 32 | - | MinWidth int | 12 | + | MinWidth int |
| 33 | - | MinHeight int | 13 | + | MinHeight int |
| 34 | - | Title string | 14 | + | Title string |
| 35 | - | Anchor Anchor | 15 | + | Anchor domain.Anchor |
| 36 | - | HorizontalAnchor HorizontalAnchor | 16 | + | Margins Margins |
| 37 | - | FixedWidth int | 17 | + | FixedWidth int |
| 38 | - | FixedHeight int | 18 | + | 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.MinHeight | 35 | 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 / 100 | 150 | 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 := 0 | 154 | 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) { | |||
| 41 | 41 | { | |
| 42 | 42 | name: "bottom anchor honors bottom margin", | |
| 43 | 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 | 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 | 54 | name: "right anchor honors right margin", | |
| 55 | 55 | base: domain.Size{Cols: 100, Rows: 40}, | |
| 56 | 56 | 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}, | |
| 61 | 61 | }, | |
| 62 | 62 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | |
| 63 | 63 | }, | |
| @@ -65,10 +65,10 @@ func TestModalBounds(t *testing.T) { | |||
| 65 | 65 | name: "right margin clamps to screen", | |
| 66 | 66 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 67 | 67 | 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}, | |
| 72 | 72 | }, | |
| 73 | 73 | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | |
| 74 | 74 | }, | |
| @@ -76,25 +76,36 @@ func TestModalBounds(t *testing.T) { | |||
| 76 | 76 | name: "right anchored fixed dimensions clamp on small terminal", | |
| 77 | 77 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 78 | 78 | 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}, | |
| 83 | 83 | }, | |
| 84 | 84 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 85 | 85 | }, | |
| 86 | 86 | { | |
| 87 | 87 | name: "fixed dimensions clamp on small terminal", | |
| 88 | 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 | 90 | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 91 | 91 | }, | |
| 92 | 92 | { | |
| 93 | 93 | name: "bottom margin clamps to screen", | |
| 94 | 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 | 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 | 110 | for _, tt := range tests { | |
| 100 | 111 | 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 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "strconv" | |
| 5 | 4 | "time" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/vev/internal/domain" | |
| @@ -14,22 +13,11 @@ const ( | |||
| 14 | 13 | 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 | 16 | // Toast describes a transient message rendered over a frame. | |
| 29 | 17 | type Toast struct { | |
| 30 | 18 | ID string | |
| 31 | 19 | Message string | |
| 32 | - | Anchor ToastAnchor | |
| 20 | + | Anchor domain.Anchor | |
| 33 | 21 | Duration time.Duration | |
| 34 | 22 | DimBackground bool | |
| 35 | 23 | MinWidth int | |
| @@ -76,8 +64,8 @@ func (m *ToastManager) Show(now time.Time, toast Toast) { | |||
| 76 | 64 | m.active[id] = ActiveToast{Toast: toast, ShownAt: now} | |
| 77 | 65 | } | |
| 78 | 66 | ||
| 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() | |
| 81 | 69 | } | |
| 82 | 70 | ||
| 83 | 71 | // Dismiss removes a toast by ID. | |
| @@ -136,32 +124,12 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect { | |||
| 136 | 124 | height := 1 + toast.PaddingY*2 + borderHeight | |
| 137 | 125 | height = clamp(height, 0, base.Rows) | |
| 138 | 126 | ||
| 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 | + | }) | |
| 165 | 133 | } | |
| 166 | 134 | ||
| 167 | 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 | 166 | func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast { | |
| 199 | - | byAnchor := make(map[ToastAnchor]ActiveToast) | |
| 167 | + | byAnchor := make(map[domain.Anchor]ActiveToast) | |
| 200 | 168 | for _, toast := range toasts { | |
| 201 | 169 | current, ok := byAnchor[toast.Anchor] | |
| 202 | 170 | if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) { | |
| 203 | 171 | 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 | 185 | visible := make([]ActiveToast, 0, len(byAnchor)) | |
| 208 | 186 | for _, anchor := range anchors { | |
| 209 | 187 | if toast, ok := byAnchor[anchor]; ok { | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package ui | 1 | 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 = 60 | 13 | 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 string | 18 | ID string | ||
| 31 | Message string | 19 | Message string | ||
| 32 | - | Anchor ToastAnchor | 20 | + | Anchor domain.Anchor |
| 33 | Duration time.Duration | 21 | Duration time.Duration | ||
| 34 | DimBackground bool | 22 | DimBackground bool | ||
| 35 | MinWidth int | 23 | 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 + borderHeight | 124 | height := 1 + toast.PaddingY*2 + borderHeight | ||
| 137 | height = clamp(height, 0, base.Rows) | 125 | height = clamp(height, 0, base.Rows) | ||
| 138 | 126 | ||||
| 139 | - | x := 0 | 127 | + | return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{ |
| 140 | - | y := 0 | 128 | + | Top: defaultToastMargin, |
| 141 | - | switch toast.Anchor { | 129 | + | Right: defaultToastMargin, |
| 142 | - | case ToastTopLeft: | 130 | + | Bottom: defaultToastMargin, |
| 143 | - | x = defaultToastMargin | 131 | + | Left: defaultToastMargin, |
| 144 | - | y = defaultToastMargin | 132 | + | }) |
| 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] = toast | 171 | 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) { | |||
| 54 | 54 | } | |
| 55 | 55 | }) | |
| 56 | 56 | ||
| 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 | + | } | |
| 64 | 70 | } | |
| 65 | 71 | }) | |
| 66 | 72 | ||
| @@ -83,64 +89,29 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | |||
| 83 | 89 | } | |
| 84 | 90 | ||
| 85 | 91 | 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 | |
| 90 | 96 | }{ | |
| 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}}, | |
| 139 | 110 | } | |
| 140 | 111 | ||
| 141 | 112 | for _, tt := range tests { | |
| 142 | 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 | 115 | t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want) | |
| 145 | 116 | } | |
| 146 | 117 | }) | |
| @@ -155,8 +126,8 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||
| 155 | 126 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 156 | 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) | |
| 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}) | |
| 160 | 131 | if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) { | |
| 161 | 132 | t.Fatalf("bounds = %+v, want centered 6x3 box", bounds) | |
| 162 | 133 | } | |
| @@ -174,7 +145,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||
| 174 | 145 | ||
| 175 | 146 | t.Run("narrow toast truncates with ellipsis", func(t *testing.T) { | |
| 176 | 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 | 149 | assertRune(t, f, 0, 0, '┌') | |
| 179 | 150 | assertRune(t, f, 7, 0, '┐') | |
| 180 | 151 | assertRune(t, f, 2, 1, 'a') | |
| @@ -190,9 +161,9 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) { | |||
| 190 | 161 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}} | |
| 191 | 162 | ||
| 192 | 163 | 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}, | |
| 196 | 167 | }, styles, nil) | |
| 197 | 168 | ||
| 198 | 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 | 208 | func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |
| 211 | 209 | f := renderer.NewFrame(14, 5) | |
| 212 | 210 | baseStyle := renderer.Style{Foreground: 1, Background: 2} | |
| @@ -218,7 +216,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |||
| 218 | 216 | } | |
| 219 | 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 | 220 | return dimStyle | |
| 223 | 221 | }) | |
| 224 | 222 | ||
| @@ -229,7 +227,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |||
| 229 | 227 | if !outside.Style.Equal(dimStyle) { | |
| 230 | 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 | 231 | boxCell := f.At(bounds.X, bounds.Y) | |
| 234 | 232 | if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) { | |
| 235 | 233 | t.Fatalf("box cell = %+v, want toast box style", boxCell) | |
| @@ -245,7 +243,7 @@ func TestCompositeToastsHandlesTinyFrames(t *testing.T) { | |||
| 245 | 243 | for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} { | |
| 246 | 244 | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | |
| 247 | 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 | } | |
| @@ -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 string | 92 | + | name string |
| 87 | - | base domain.Size | 93 | + | base domain.Size |
| 88 | - | make func() Toast | 94 | + | toast Toast |
| 89 | - | want domain.Rect | 95 | + | 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 dimStyle | 220 | 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 | } | ||