From 5bbb3f42c932d2b894c5bd67c62bace1b62238a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:26:06 +0200 Subject: [PATCH 1/2] feat: the logo on top of the status pane's rows Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/keys.md | 3 ++- internal/ui/app/app_test.go | 7 ++++++- internal/ui/app/fields.go | 2 ++ internal/ui/app/status.go | 10 ++++++++++ internal/ui/panes/list.go | 29 +++++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/keys.md b/docs/keys.md index d087f15..3d4185a 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -113,7 +113,8 @@ Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`, it's hidden. On top, the status pane (`0`) shows the version, a newer release and how -your accounts are doing. Focused, the right pane lists more: the version +your accounts are doing. Focused, the right pane lists more under the +logo (when there's room): the version and updates (`U`), each account (`L` on one that needs it signs in), the config, state and cache files (`e` opens one in your editor), and links to the docs, source, issues, releases and changelog (`o` opens one; on diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index deac928..56bd1b2 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1190,7 +1190,7 @@ func TestStatusPane(t *testing.T) { t.Fatalf("focus %v", d.m.focus) } scr := d.screen() - for _, want := range []string{"version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required", + for _, want := range []string{logo[2], "version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required", "/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} { if !strings.Contains(scr, want) { t.Errorf("missing %q:\n%s", want, scr) @@ -1216,6 +1216,11 @@ func TestStatusPane(t *testing.T) { if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") { t.Errorf("o opened %q", opened) } + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "version" }) + d.keys("o") + if len(opened) != 2 || !strings.HasSuffix(opened[1], "/releases/tag/v0.1.0") { + t.Errorf("o on the version opened %q", opened) + } d.keys("shift+tab") if d.m.focus != paneStatus { t.Errorf("back to %v", d.m.focus) diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index bb6fa6f..613c526 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -50,6 +50,7 @@ func (m *Model) syncFields() tea.Cmd { m.fields.Hide() return nil } + m.fields.Header = nil switch m.detailOf() { case paneSources: r, ok := m.sources.Selected() @@ -68,6 +69,7 @@ func (m *Model) syncFields() tea.Cmd { m.fields.Show("", nil) } case paneStatus: + m.fields.Header = logo m.fields.Show("lazysecrets", m.statusFields()) case paneContainers: if c, ok := m.containers.Selected(); ok { diff --git a/internal/ui/app/status.go b/internal/ui/app/status.go index c3da3e5..4e2de7f 100644 --- a/internal/ui/app/status.go +++ b/internal/ui/app/status.go @@ -16,6 +16,16 @@ import ( // (0), the right pane has the rest: updates, each account, the files // lazysecrets uses, links, and the changelog. +// logo heads the status pane's rows. +var logo = []string{ + ` _ _`, + `| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`, + `| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`, + `| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`, + `|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`, + ` |___/`, +} + // statusH is the status pane's height: one line in a box. const statusH = 3 diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index 0b9478f..144c924 100644 --- a/internal/ui/panes/list.go +++ b/internal/ui/panes/list.go @@ -37,6 +37,9 @@ type List[T any] struct { // Age says how old the items are ("2h ago") while they reload, by // the counter. Age string + // Header lines (a logo) go above the items, when the pane has room + // for them and there's no filter. + Header []string item Item[T] items []T @@ -163,9 +166,29 @@ func (l List[T]) rows() int { if l.filtering { r-- } + if l.headerShown() { + r -= len(l.Header) + 1 + } return max(0, r) } +// headerMinRows is how many item rows the header leaves at least. +const headerMinRows = 5 + +// headerShown reports whether Header fits: as wide as the pane allows, +// leaving headerMinRows, a blank line under it, and no filter. +func (l List[T]) headerShown() bool { + if len(l.Header) == 0 || l.filtering || l.input.Value() != "" { + return false + } + for _, line := range l.Header { + if ansi.StringWidth(line) > l.w-3 { + return false + } + } + return l.h-2-len(l.Header)-1 >= headerMinRows +} + // Filtering reports whether the filter input has focus. func (l List[T]) Filtering() bool { return l.filtering } @@ -329,6 +352,12 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { if l.filtering { body = append(body, l.input.View()) } + if l.headerShown() { + for _, line := range l.Header { + body = append(body, st.Notice.Render(" "+line)) + } + body = append(body, "") + } switch { case l.Loading && len(l.items) == 0: -- 2.54.0 From d9de14f9269e1cf48fe00950a2353a1a04a83c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:33:18 +0200 Subject: [PATCH 2/2] feat: the status pane's logo turns to a line of text further down, or without room Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/fields.go | 4 ++-- internal/ui/panes/list.go | 42 +++++++++++++++++++--------------- internal/ui/panes/list_test.go | 24 +++++++++++++++++++ 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index 613c526..d06f0b6 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -50,7 +50,7 @@ func (m *Model) syncFields() tea.Cmd { m.fields.Hide() return nil } - m.fields.Header = nil + m.fields.Header, m.fields.HeaderShort = nil, "" switch m.detailOf() { case paneSources: r, ok := m.sources.Selected() @@ -69,7 +69,7 @@ func (m *Model) syncFields() tea.Cmd { m.fields.Show("", nil) } case paneStatus: - m.fields.Header = logo + m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel()) m.fields.Show("lazysecrets", m.statusFields()) case paneContainers: if c, ok := m.containers.Selected(); ok { diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index 144c924..c1feb06 100644 --- a/internal/ui/panes/list.go +++ b/internal/ui/panes/list.go @@ -37,9 +37,11 @@ type List[T any] struct { // Age says how old the items are ("2h ago") while they reload, by // the counter. Age string - // Header lines (a logo) go above the items, when the pane has room - // for them and there's no filter. - Header []string + // Header lines (a logo) go above the items while the cursor is in + // the rows under it, the pane has room and there's no filter; else + // HeaderShort, a line of plain text, does. + Header []string + HeaderShort string item Item[T] items []T @@ -166,27 +168,34 @@ func (l List[T]) rows() int { if l.filtering { r-- } - if l.headerShown() { - r -= len(l.Header) + 1 - } + r -= len(l.header()) return max(0, r) } -// headerMinRows is how many item rows the header leaves at least. +// headerMinRows is how many item rows Header leaves at least. const headerMinRows = 5 -// headerShown reports whether Header fits: as wide as the pane allows, -// leaving headerMinRows, a blank line under it, and no filter. -func (l List[T]) headerShown() bool { +// header is the lines above the items: Header and a blank line, if it +// fits, leaves headerMinRows and the cursor is in the rows under it; +// else HeaderShort. +func (l List[T]) header() []string { + short := []string{} + if l.HeaderShort != "" { + short = []string{l.HeaderShort} + } if len(l.Header) == 0 || l.filtering || l.input.Value() != "" { - return false + return short } for _, line := range l.Header { if ansi.StringWidth(line) > l.w-3 { - return false + return short } } - return l.h-2-len(l.Header)-1 >= headerMinRows + below := l.h - 2 - len(l.Header) - 1 + if below < headerMinRows || l.cursor >= below { + return short + } + return append(l.Header[:len(l.Header):len(l.Header)], "") } // Filtering reports whether the filter input has focus. @@ -352,11 +361,8 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { if l.filtering { body = append(body, l.input.View()) } - if l.headerShown() { - for _, line := range l.Header { - body = append(body, st.Notice.Render(" "+line)) - } - body = append(body, "") + for _, line := range l.header() { + body = append(body, st.Notice.Render(" "+line)) } switch { diff --git a/internal/ui/panes/list_test.go b/internal/ui/panes/list_test.go index 2b2209d..aa8aafc 100644 --- a/internal/ui/panes/list_test.go +++ b/internal/ui/panes/list_test.go @@ -177,3 +177,27 @@ func TestHighlightKeepsColours(t *testing.T) { t.Error("reverse kept the colours") } } + +func TestListHeader(t *testing.T) { + l := NewList("t", Item[string]{ + ID: func(s string) string { return s }, + Filter: func(s string) string { return s }, + Render: func(_ styles.Styles, s string, _ int) string { return s }, + }) + l.Header, l.HeaderShort = []string{"ART1", "ART2"}, "short" + l.SetItems([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}) + l.SetSize(30, 2+2+1+6) // the art, a blank line and 6 rows + st := styles.Default() + if v := l.View(st, true, ""); !strings.Contains(v, "ART1") || strings.Contains(v, "short") { + t.Errorf("at the top, the art:\n%s", v) + } + l.Move(6) // below the rows under the art + if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") || !strings.Contains(v, " g") { + t.Errorf("further down, the text:\n%s", v) + } + l.Top() + l.SetSize(30, 8) // too short for the art and 5 rows + if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") { + t.Errorf("no room, the text:\n%s", v) + } +} -- 2.54.0