diff --git a/docs/assets/sesh-ui-menu.png b/docs/assets/sesh-ui-menu.png index 2fbbbd0a..600760bb 100644 Binary files a/docs/assets/sesh-ui-menu.png and b/docs/assets/sesh-ui-menu.png differ diff --git a/docs/assets/sesh-ui-search.png b/docs/assets/sesh-ui-search.png index 61cc591e..5787490c 100644 Binary files a/docs/assets/sesh-ui-search.png and b/docs/assets/sesh-ui-search.png differ diff --git a/docs/assets/sesh-ui.png b/docs/assets/sesh-ui.png index d01c8a42..03acaf72 100644 Binary files a/docs/assets/sesh-ui.png and b/docs/assets/sesh-ui.png differ diff --git a/internal/tui/actions.go b/internal/tui/actions.go index 0be91341..957af03d 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -868,7 +868,7 @@ func (m *Model) drawOverlay(base string) string { k = th.key(key) } if i == ov.sel { - return lipgloss.NewStyle().Background(th.selBg).Render(th.accented().Render("▌") + " " + fitTo(th.style().Bold(true).Render(text), w-12) + " " + k) + return th.selectedRow(text, w-12) + k } return " " + fitTo(th.style().Render(text), w-12) + " " + k } diff --git a/internal/tui/theme.go b/internal/tui/theme.go index a746aecd..6644255a 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -19,7 +19,8 @@ type theme struct { func newTheme(dark bool) theme { c := lipgloss.LightDark(dark) return theme{ - accent: c(lipgloss.Color("#6E40C9"), lipgloss.Color("#B392F0")), + // The logo's blue; for a dark terminal, a lighter blue of the same hue. + accent: c(lipgloss.Color("#1165B1"), lipgloss.Color("#63ADEE")), text: c(lipgloss.Color("#1F2328"), lipgloss.Color("#E6EDF3")), dim: c(lipgloss.Color("#59636E"), lipgloss.Color("#8B949E")), faint: c(lipgloss.Color("#B7BDC8"), lipgloss.Color("#3D444D")), @@ -27,8 +28,8 @@ func newTheme(dark bool) theme { ok: c(lipgloss.Color("#1A7F37"), lipgloss.Color("#3FB950")), warn: c(lipgloss.Color("#9A6700"), lipgloss.Color("#D29922")), bad: c(lipgloss.Color("#CF222E"), lipgloss.Color("#F85149")), - selBg: c(lipgloss.Color("#F3EEFF"), lipgloss.Color("#2A2140")), - badgeText: c(lipgloss.Color("#FFFFFF"), lipgloss.Color("#1B1325")), + selBg: c(lipgloss.Color("#ECF3FB"), lipgloss.Color("#13233A")), + badgeText: c(lipgloss.Color("#FFFFFF"), lipgloss.Color("#0B1A2B")), keyBg: c(lipgloss.Color("#E6EAEF"), lipgloss.Color("#262C36")), } } @@ -40,6 +41,21 @@ func (t *theme) accented() lipgloss.Style { return lipgloss.NewStyle().Foreground(t.accent) } +// selectedRow is a panel's selected row up to its key: its bar, then +// label cut or padded to w, and a space, on one band of the selection's +// colour. The label's style, band and bold, is put back after each reset +// in it, so a label with colours of its own (the trash's dimmed details, +// a to-do's arrow) stays on the band, and bold. +func (t *theme) selectedRow(label string, w int) string { + on := lipgloss.NewStyle().Background(t.selBg) + style := t.style().Bold(true).Background(t.selBg) + start, _, _ := strings.Cut(style.Render("x"), "x") + label = style.Render(ansi.Truncate(label, w, "…")) + label = strings.ReplaceAll(strings.ReplaceAll(label, "\x1b[0m", "\x1b[m"), "\x1b[m", "\x1b[m"+start) + pad := on.Render(strings.Repeat(" ", max(w-lipgloss.Width(label), 0))) + return t.accented().Background(t.selBg).Render("▌") + on.Render(" ") + label + pad + on.Render(" ") +} + // key is a key's name drawn as a key: a small label. func (t *theme) key(name string) string { return lipgloss.NewStyle().Foreground(t.accent).Background(t.keyBg).Bold(true).Padding(0, 1).Render(name) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 038eec2b..15ca9f7a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -15,6 +15,7 @@ import ( "sync/atomic" "testing" "time" + "unicode/utf8" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -1253,6 +1254,107 @@ func TestActionMenu(t *testing.T) { } } +// cellBackgrounds is line's characters, the true-colour background code +// ("48;2;r;g;b", or "" for none) each is drawn on, and whether each is bold. +func cellBackgrounds(line string) ([]rune, []string, []bool) { + var text []rune + var bgs []string + var bolds []bool + bg, bold := "", false + for rest := line; rest != ""; { + if strings.HasPrefix(rest, "\x1b[") { + end := strings.IndexByte(rest, 'm') + params := strings.Split(rest[2:end], ";") + for k := 0; k < len(params); k++ { + switch p := params[k]; { + case p == "" || p == "0": + bg, bold = "", false + case p == "49": + bg = "" + case p == "1": + bold = true + case p == "22": + bold = false + case p == "48" && k+4 < len(params) && params[k+1] == "2": + bg = strings.Join(params[k:k+5], ";") + k += 4 + case (p == "38" || p == "58") && k+1 < len(params) && params[k+1] == "2": + k += 4 + } + } + rest = rest[end+1:] + continue + } + c, size := utf8.DecodeRuneInString(rest) + text = append(text, c) + bgs = append(bgs, bg) + bolds = append(bolds, bold) + rest = rest[size:] + } + return text, bgs, bolds +} + +// bandCode is the background code the selection's colour is drawn with. +func bandCode(th *theme) string { + r, g, b, _ := th.selBg.RGBA() + return fmt.Sprintf("48;2;%d;%d;%d", r>>8, g>>8, b>>8) +} + +// A menu's selected row is one band of colour, across the gap between its label and its key. +func TestMenuSelectedRowBand(t *testing.T) { + a := wideApp(t, newFake()) + a.press("ctrl+g") + want := bandCode(&a.th) + for line := range strings.SplitSeq(a.View().Content, "\n") { + if !strings.Contains(ansi.Strip(line), "add an entry…") { + continue + } + runes, bgs, _ := cellBackgrounds(line) + from := strings.Index(string(runes), "add an entry…") + from = utf8.RuneCountInString(string(runes)[:from]) + utf8.RuneCountInString("add an entry…") + // Up to the key's label, which starts with its own padding space. + end := from + for end < len(runes) && runes[end] == ' ' { + end++ + } + if end-1-from < 10 { + t.Fatalf("no gap after the label: %q", line) + } + for k := from; k < end-1; k++ { + if bgs[k] != want { + t.Fatalf("the gap after the selected row's label isn't in the band, at %d: %q", k, line) + } + } + return + } + t.Fatalf("no selected row:\n%s", a.screen()) +} + +// A selected row whose label has colours of its own (the trash's, a to-do's) stays on the band, and bold, throughout. +func TestSelectedRowStyledLabel(t *testing.T) { + for _, dark := range []bool{true, false} { + th := newTheme(dark) + label := th.accented().Render("→") + " run sesh doctor " + th.dimmed().Render("deleted 3 days ago") + row := th.selectedRow(label, 40) + runes, bgs, bolds := cellBackgrounds(row) + if string(runes) != "▌ → run sesh doctor deleted 3 days ago " { + t.Fatalf("dark %v: row %q", dark, string(runes)) + } + for k, bg := range bgs { + if bg != bandCode(&th) { + t.Fatalf("dark %v: %q at %d isn't on the band: %q", dark, runes[k], k, row) + } + } + // The words after the arrow's colour are bold, as the row's are. + from := slices.Index(runes, 'r') + for k := from; k < from+len("run sesh doctor"); k++ { + if !bolds[k] { + t.Fatalf("dark %v: %q at %d isn't bold: %q", dark, runes[k], k, row) + } + } + } +} + // Ctrl-G opens sesh's own actions from the search, the list or the sidebar; Ctrl-K with no entry to act on does too. func TestSeshMenu(t *testing.T) { a := wideApp(t, newFake())