From 4aad810ee791c52ea2f50a2c0299f93c7b8b4c2b Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 30 Aug 2026 20:27:49 +0700 Subject: [PATCH] feat(ui): accept a percentage for ui.max-width --- README.md | 2 +- integration/overlay.go | 2 +- internal/config/config.go | 5 +- internal/config/defaults.go | 2 +- internal/config/width.go | 72 ++++++++++++++++++++++++ internal/config/width_test.go | 100 ++++++++++++++++++++++++++++++++++ root/config_cmd.go | 3 +- root/init.go | 3 +- tests/tui/maxwidth_test.go | 71 ++++++++++++++++++++++++ 9 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 internal/config/width.go create mode 100644 internal/config/width_test.go create mode 100644 tests/tui/maxwidth_test.go diff --git a/README.md b/README.md index 914272b..c86f554 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,7 @@ ghost-text = 1 # 0 = off, 1 = menu + ghost text, 2 = ghost text only hidden-files = false # include dotfiles in suggestions max-suggestions = 100 # max suggestions ranked before display max-height = 6 # max visible rows in the menu -max-width = 0 # max menu width, 0 = auto +max-width = 0 # menu width: columns (80) or a share ("80%"), 0 = default nerd-fonts = true # use nerd-font icons [keybindings] diff --git a/integration/overlay.go b/integration/overlay.go index 8cc55db..9af5663 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -666,7 +666,7 @@ func (o *Overlay) draw() string { // current line. When the prompt is wider than the terminal the cursor has // wrapped, so using PromptLen directly overflows the screen and the box // lands at the wrong horizontal position. - boxWidth := config.Get().UI.MaxWidth + boxWidth := config.Get().UI.MaxWidth.Resolve(width) if boxWidth <= 0 { boxWidth = 76 // Default if 0 } diff --git a/internal/config/config.go b/internal/config/config.go index 3f27dac..236bb48 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,7 +86,7 @@ type UIConfig struct { ShowHiddenFiles bool `toml:"hidden-files"` MaxSuggestions int `toml:"max-suggestions"` MaxHeight int `toml:"max-height"` - MaxWidth int `toml:"max-width"` + MaxWidth Width `toml:"max-width"` NerdFonts bool `toml:"nerd-fonts"` } @@ -352,6 +352,9 @@ func validate(cfg *Config) error { return fmt.Errorf("ui.max-suggestions: must be between 1 and 500") } + if err := cfg.UI.MaxWidth.validate(); err != nil { + return fmt.Errorf("ui.max-width: %w", err) + } if cfg.UI.MaxHeight < 1 || cfg.UI.MaxHeight > 50 { return fmt.Errorf("ui.max-height: must be between 1 and 50") } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index b84902f..b06463e 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -23,7 +23,7 @@ func DefaultConfig() *Config { ShowHiddenFiles: false, MaxSuggestions: 100, MaxHeight: 6, - MaxWidth: 0, // 0 means no limit, fallback to terminal width + MaxWidth: Width{}, // unset; the overlay falls back to its own default width NerdFonts: true, }, Git: GitConfig{ diff --git a/internal/config/width.go b/internal/config/width.go new file mode 100644 index 0000000..6f8da77 --- /dev/null +++ b/internal/config/width.go @@ -0,0 +1,72 @@ +package config + +import ( + "fmt" + "strconv" + "strings" +) + +// Width is a width setting written either as a column count (80) or as a share +// of the terminal ("80%"). Percentages only mean something once the terminal +// size is known, so both forms are carried through to draw time and resolved +// there. +type Width struct { + n int + percent bool +} + +// Resolve returns the column count this setting asks for on a terminal of the +// given width. Zero means unset, and is left for the caller to default. +func (w Width) Resolve(term int) int { + if !w.percent { + return w.n + } + if term <= 0 { + return 0 + } + return term * w.n / 100 +} + +func (w Width) String() string { + if w.percent { + return strconv.Itoa(w.n) + "%" + } + return strconv.Itoa(w.n) +} + +func (w *Width) UnmarshalTOML(v any) error { + switch t := v.(type) { + case int64: + *w = Width{n: int(t)} + return nil + case string: + body, percent := strings.CutSuffix(strings.TrimSpace(t), "%") + n, err := strconv.Atoi(strings.TrimSpace(body)) + if err != nil { + return fmt.Errorf("invalid value %q (want a column count like 80, or a share like \"80%%\")", t) + } + *w = Width{n: n, percent: percent} + return nil + } + return fmt.Errorf("invalid value of type %T (want a column count like 80, or a share like \"80%%\")", v) +} + +func (w Width) MarshalTOML() ([]byte, error) { + if w.percent { + return []byte(strconv.Quote(w.String())), nil + } + return []byte(w.String()), nil +} + +func (w Width) validate() error { + if w.percent { + if w.n < 1 || w.n > 100 { + return fmt.Errorf("must be between 1%% and 100%%") + } + return nil + } + if w.n < 0 { + return fmt.Errorf("must not be negative") + } + return nil +} diff --git a/internal/config/width_test.go b/internal/config/width_test.go new file mode 100644 index 0000000..73b8e8e --- /dev/null +++ b/internal/config/width_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "testing" + + "github.com/BurntSushi/toml" +) + +func TestWidthAcceptsBothForms(t *testing.T) { + cases := []struct { + toml string + want Width + }{ + {`max-width = 80`, Width{n: 80}}, + {`max-width = 0`, Width{}}, + {`max-width = "80%"`, Width{n: 80, percent: true}}, + {`max-width = "100%"`, Width{n: 100, percent: true}}, + {`max-width = " 60 % "`, Width{n: 60, percent: true}}, + {`max-width = "80"`, Width{n: 80}}, + } + for _, c := range cases { + var got struct { + MaxWidth Width `toml:"max-width"` + } + if _, err := toml.Decode(c.toml, &got); err != nil { + t.Errorf("decode %q: %v", c.toml, err) + continue + } + if got.MaxWidth != c.want { + t.Errorf("decode %q = %+v; want %+v", c.toml, got.MaxWidth, c.want) + } + } +} + +func TestWidthRejectsNonsense(t *testing.T) { + for _, in := range []string{`max-width = "wide"`, `max-width = "%"`, `max-width = "8 0%"`, `max-width = true`} { + var got struct { + MaxWidth Width `toml:"max-width"` + } + if _, err := toml.Decode(in, &got); err == nil { + t.Errorf("decode %q: want an error, got %+v", in, got.MaxWidth) + } + } +} + +func TestWidthResolve(t *testing.T) { + cases := []struct { + w Width + term int + want int + }{ + {Width{n: 80}, 200, 80}, // absolute ignores the terminal + {Width{n: 80, percent: true}, 200, 160}, + {Width{n: 50, percent: true}, 81, 40}, // truncates rather than rounds up + {Width{n: 80, percent: true}, 0, 0}, // unknown terminal falls through + {Width{}, 200, 0}, // unset falls through + } + for _, c := range cases { + if got := c.w.Resolve(c.term); got != c.want { + t.Errorf("Width%+v.Resolve(%d) = %d; want %d", c.w, c.term, got, c.want) + } + } +} + +func TestWidthValidate(t *testing.T) { + ok := []Width{{}, {n: 80}, {n: 1, percent: true}, {n: 100, percent: true}} + for _, w := range ok { + if err := w.validate(); err != nil { + t.Errorf("Width%+v.validate() = %v; want nil", w, err) + } + } + bad := []Width{{n: -1}, {n: 0, percent: true}, {n: 101, percent: true}} + for _, w := range bad { + if err := w.validate(); err == nil { + t.Errorf("Width%+v.validate() = nil; want an error", w) + } + } +} + +// iris config show round-trips the config through the encoder, so both forms +// have to come back out the way they went in. +func TestWidthRoundTrips(t *testing.T) { + for _, in := range []string{`max-width = 80`, `max-width = "80%"`} { + var cfg struct { + MaxWidth Width `toml:"max-width"` + } + if _, err := toml.Decode(in, &cfg); err != nil { + t.Fatal(err) + } + var out []byte + b, err := cfg.MaxWidth.MarshalTOML() + if err != nil { + t.Fatal(err) + } + out = append([]byte("max-width = "), b...) + if string(out) != in { + t.Errorf("round trip of %q = %q", in, out) + } + } +} diff --git a/root/config_cmd.go b/root/config_cmd.go index 898da9c..22db621 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -92,7 +92,8 @@ max-suggestions = 100 # maximum suggestion rows shown in the menu max-height = 6 -# maximum width of the overlay (0 = responsive to terminal) +# overlay width, as columns (80) or a share of the terminal ("80%") +# 0 keeps the built-in default width max-width = 0 [git] diff --git a/root/init.go b/root/init.go index d34a8f9..8b3d8a9 100644 --- a/root/init.go +++ b/root/init.go @@ -279,7 +279,8 @@ max-suggestions = 100 # maximum suggestion rows shown in the menu max-height = 6 -# maximum width of the overlay (0 = responsive to terminal) +# overlay width, as columns (80) or a share of the terminal ("80%") +# 0 keeps the built-in default width max-width = 0 [git] diff --git a/tests/tui/maxwidth_test.go b/tests/tui/maxwidth_test.go new file mode 100644 index 0000000..9ebc18f --- /dev/null +++ b/tests/tui/maxwidth_test.go @@ -0,0 +1,71 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gaurav-Gosain/tuitest" +) + +// boxWidth measures the drawn box from its top border. +func boxWidth(t *testing.T, term *tuitest.Terminal) int { + t.Helper() + b := findBox(term) + if b.top < 0 || len(b.rows) == 0 { + t.Fatalf("no box on screen:\n%s", screen(term)) + } + top := b.rows[0] + start := strings.Index(top, "╭") + end := strings.LastIndex(top, "╮") + if start < 0 || end < 0 { + t.Fatalf("no top border on screen:\n%s", screen(term)) + } + return len([]rune(top[start:])) - len([]rune(top[end:])) + 1 +} + +func startWithMaxWidth(t *testing.T, value string) *tuitest.Terminal { + t.Helper() + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + cfg := "version = 1\n\n[ui]\nmax-width = " + value + "\n" + if err := os.WriteFile(filepath.Join(home, ".config/iris/config.toml"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + term := startIn(t, home) + if err := term.Type("nvi"); err != nil { + t.Fatal(err) + } + if err := term.WaitForText("Accept", 10*time.Second); err != nil { + t.Fatalf("menu never appeared: %v\n%s", err, screen(term)) + } + return term +} + +// A percentage has to scale with the terminal; a column count must not. +func TestMaxWidthAcceptsColumnsAndPercentages(t *testing.T) { + cases := []struct { + value string + want int + }{ + {"90", 90}, + {`"80%"`, cols * 80 / 100}, + {`"50%"`, cols * 50 / 100}, + {"0", 76}, // unset keeps the overlay's own default + } + for _, c := range cases { + t.Run(c.value, func(t *testing.T) { + term := startWithMaxWidth(t, c.value) + defer func() { _ = term.Close() }() + if got := boxWidth(t, term); got != c.want { + t.Errorf("max-width = %s drew a box %d columns wide; want %d\n%s", c.value, got, c.want, screen(term)) + } + }) + } +}