Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion integration/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
72 changes: 72 additions & 0 deletions internal/config/width.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions internal/config/width_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
3 changes: 2 additions & 1 deletion root/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion root/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
71 changes: 71 additions & 0 deletions tests/tui/maxwidth_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
})
}
}
Loading