Merge remote-tracking branch 'origin/main' into feat/recent-session-jump
# Conflicts: # internal/usecase/daemon/status.go
13 files · +633 -121
M README.md +1 -1
| @@ -223,7 +223,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously | |||
| 223 | 223 | ||
| 224 | 224 | While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command. | |
| 225 | 225 | ||
| 226 | - | The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`. | |
| 226 | + | The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. On truecolor clients the name renders bold and the parenthesized title slightly muted (in both the top bar and the session picker), derived from the client's reported theme; other terminals render plain labels. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`. | |
| 227 | 227 | ||
| 228 | 228 | ## Scrollback mode and copy | |
| 229 | 229 | ||
| @@ -223,7 +223,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously | |||||
| 223 | 223 | ||||
| 224 | While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command. | 224 | While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command. | ||
| 225 | 225 | ||||
| 226 | - | The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`. | 226 | + | The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. On truecolor clients the name renders bold and the parenthesized title slightly muted (in both the top bar and the session picker), derived from the client's reported theme; other terminals render plain labels. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`. |
| 227 | 227 | ||||
| 228 | ## Scrollback mode and copy | 228 | ## Scrollback mode and copy | ||
| 229 | 229 | ||||
M internal/usecase/daemon/attention_test.go +7 -6
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | 18 | "github.com/bnema/vev/pkg/renderer" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -54,7 +55,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |||
| 54 | 55 | ||
| 55 | 56 | d.paint(sess, ac, true) | |
| 56 | 57 | data := mustOutputData(t, sends) | |
| 57 | - | require.NotContains(t, string(data), string(attentionGlyph)) | |
| 58 | + | require.NotContains(t, string(data), string(ui.AttentionGlyph)) | |
| 58 | 59 | ||
| 59 | 60 | sess.mu.Lock() | |
| 60 | 61 | require.True(t, sess.tabs[0].attention) | |
| @@ -63,7 +64,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |||
| 63 | 64 | d.setAttentionFrame(1) | |
| 64 | 65 | d.paint(sess, ac, true) | |
| 65 | 66 | data = mustOutputData(t, sends) | |
| 66 | - | require.Contains(t, string(data), string(attentionGlyph)) | |
| 67 | + | require.Contains(t, string(data), string(ui.AttentionGlyph)) | |
| 67 | 68 | ||
| 68 | 69 | sess.mu.Lock() | |
| 69 | 70 | require.False(t, sess.tabs[0].attention) | |
| @@ -98,7 +99,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||
| 98 | 99 | ||
| 99 | 100 | d.paint(sess, ac, true) | |
| 100 | 101 | data := mustOutputData(t, sends) | |
| 101 | - | require.Contains(t, string(data), string(attentionGlyph)) | |
| 102 | + | require.Contains(t, string(data), string(ui.AttentionGlyph)) | |
| 102 | 103 | ||
| 103 | 104 | sess.mu.Lock() | |
| 104 | 105 | require.False(t, sess.tabs[0].attention) | |
| @@ -106,7 +107,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||
| 106 | 107 | sess.mu.Unlock() | |
| 107 | 108 | ||
| 108 | 109 | cleared := mustOutputData(t, sends) | |
| 109 | - | require.NotContains(t, string(cleared), string(attentionGlyph)) | |
| 110 | + | require.NotContains(t, string(cleared), string(ui.AttentionGlyph)) | |
| 110 | 111 | ||
| 111 | 112 | _ = pty.Close() | |
| 112 | 113 | d.sessWg.Wait() | |
| @@ -429,13 +430,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 429 | 430 | ||
| 430 | 431 | d.paint(sessB, acB, true) | |
| 431 | 432 | before := mustOutputData(t, sendsB) | |
| 432 | - | require.Contains(t, string(before), string(attentionGlyph)) | |
| 433 | + | require.Contains(t, string(before), string(ui.AttentionGlyph)) | |
| 433 | 434 | ||
| 434 | 435 | d.closeTab(sessA, ringing, true) | |
| 435 | 436 | _ = mustOutputData(t, sendsA) | |
| 436 | 437 | ||
| 437 | 438 | after := mustOutputData(t, sendsB) | |
| 438 | - | require.NotContains(t, string(after), string(attentionGlyph)) | |
| 439 | + | require.NotContains(t, string(after), string(ui.AttentionGlyph)) | |
| 439 | 440 | } | |
| 440 | 441 | ||
| 441 | 442 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 16 | "github.com/bnema/vev/internal/usecase/keys" | 16 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 17 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 17 | "github.com/bnema/vev/pkg/renderer" | 18 | "github.com/bnema/vev/pkg/renderer" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| @@ -54,7 +55,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |||||
| 54 | 55 | ||||
| 55 | d.paint(sess, ac, true) | 56 | d.paint(sess, ac, true) | ||
| 56 | data := mustOutputData(t, sends) | 57 | data := mustOutputData(t, sends) | ||
| 57 | - | require.NotContains(t, string(data), string(attentionGlyph)) | 58 | + | require.NotContains(t, string(data), string(ui.AttentionGlyph)) |
| 58 | 59 | ||||
| 59 | sess.mu.Lock() | 60 | sess.mu.Lock() | ||
| 60 | require.True(t, sess.tabs[0].attention) | 61 | require.True(t, sess.tabs[0].attention) | ||
| @@ -63,7 +64,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |||||
| 63 | d.setAttentionFrame(1) | 64 | d.setAttentionFrame(1) | ||
| 64 | d.paint(sess, ac, true) | 65 | d.paint(sess, ac, true) | ||
| 65 | data = mustOutputData(t, sends) | 66 | data = mustOutputData(t, sends) | ||
| 66 | - | require.Contains(t, string(data), string(attentionGlyph)) | 67 | + | require.Contains(t, string(data), string(ui.AttentionGlyph)) |
| 67 | 68 | ||||
| 68 | sess.mu.Lock() | 69 | sess.mu.Lock() | ||
| 69 | require.False(t, sess.tabs[0].attention) | 70 | require.False(t, sess.tabs[0].attention) | ||
| @@ -98,7 +99,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||||
| 98 | 99 | ||||
| 99 | d.paint(sess, ac, true) | 100 | d.paint(sess, ac, true) | ||
| 100 | data := mustOutputData(t, sends) | 101 | data := mustOutputData(t, sends) | ||
| 101 | - | require.Contains(t, string(data), string(attentionGlyph)) | 102 | + | require.Contains(t, string(data), string(ui.AttentionGlyph)) |
| 102 | 103 | ||||
| 103 | sess.mu.Lock() | 104 | sess.mu.Lock() | ||
| 104 | require.False(t, sess.tabs[0].attention) | 105 | require.False(t, sess.tabs[0].attention) | ||
| @@ -106,7 +107,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||||
| 106 | sess.mu.Unlock() | 107 | sess.mu.Unlock() | ||
| 107 | 108 | ||||
| 108 | cleared := mustOutputData(t, sends) | 109 | cleared := mustOutputData(t, sends) | ||
| 109 | - | require.NotContains(t, string(cleared), string(attentionGlyph)) | 110 | + | require.NotContains(t, string(cleared), string(ui.AttentionGlyph)) |
| 110 | 111 | ||||
| 111 | _ = pty.Close() | 112 | _ = pty.Close() | ||
| 112 | d.sessWg.Wait() | 113 | d.sessWg.Wait() | ||
| @@ -429,13 +430,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 429 | 430 | ||||
| 430 | d.paint(sessB, acB, true) | 431 | d.paint(sessB, acB, true) | ||
| 431 | before := mustOutputData(t, sendsB) | 432 | before := mustOutputData(t, sendsB) | ||
| 432 | - | require.Contains(t, string(before), string(attentionGlyph)) | 433 | + | require.Contains(t, string(before), string(ui.AttentionGlyph)) |
| 433 | 434 | ||||
| 434 | d.closeTab(sessA, ringing, true) | 435 | d.closeTab(sessA, ringing, true) | ||
| 435 | _ = mustOutputData(t, sendsA) | 436 | _ = mustOutputData(t, sendsA) | ||
| 436 | 437 | ||||
| 437 | after := mustOutputData(t, sendsB) | 438 | after := mustOutputData(t, sendsB) | ||
| 438 | - | require.NotContains(t, string(after), string(attentionGlyph)) | 439 | + | require.NotContains(t, string(after), string(ui.AttentionGlyph)) |
| 439 | } | 440 | } | ||
| 440 | 441 | ||||
| 441 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | 442 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | ||
M internal/usecase/daemon/pane_titles.go +10 -2
| @@ -72,10 +72,18 @@ func (p *pane) refreshTerminalTitleLocked() { | |||
| 72 | 72 | // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal | |
| 73 | 73 | // to the tab name, collapses to just the name. | |
| 74 | 74 | func composeTabTitle(tabName, paneTitle string) string { | |
| 75 | + | return tabName + tabTitleDetail(tabName, paneTitle) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // tabTitleDetail returns the parenthesized " (paneTitle)" suffix for | |
| 79 | + | // tabName/paneTitle, or "" when paneTitle is empty or equals tabName. This is | |
| 80 | + | // the single place the name/detail composition rule lives; composeTabTitle | |
| 81 | + | // and the picker's TabEntry.Detail both build on it. | |
| 82 | + | func tabTitleDetail(tabName, paneTitle string) string { | |
| 75 | 83 | if paneTitle == "" || paneTitle == tabName { | |
| 76 | - | return tabName | |
| 84 | + | return "" | |
| 77 | 85 | } | |
| 78 | - | return tabName + " (" + paneTitle + ")" | |
| 86 | + | return " (" + paneTitle + ")" | |
| 79 | 87 | } | |
| 80 | 88 | ||
| 81 | 89 | // focusedPaneTitle returns the focused pane's display title for tab labels. | |
| @@ -72,10 +72,18 @@ func (p *pane) refreshTerminalTitleLocked() { | |||||
| 72 | // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal | 72 | // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal | ||
| 73 | // to the tab name, collapses to just the name. | 73 | // to the tab name, collapses to just the name. | ||
| 74 | func composeTabTitle(tabName, paneTitle string) string { | 74 | func composeTabTitle(tabName, paneTitle string) string { | ||
| 75 | + | return tabName + tabTitleDetail(tabName, paneTitle) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // tabTitleDetail returns the parenthesized " (paneTitle)" suffix for | |||
| 79 | + | // tabName/paneTitle, or "" when paneTitle is empty or equals tabName. This is | |||
| 80 | + | // the single place the name/detail composition rule lives; composeTabTitle | |||
| 81 | + | // and the picker's TabEntry.Detail both build on it. | |||
| 82 | + | func tabTitleDetail(tabName, paneTitle string) string { | |||
| 75 | if paneTitle == "" || paneTitle == tabName { | 83 | if paneTitle == "" || paneTitle == tabName { | ||
| 76 | - | return tabName | 84 | + | return "" |
| 77 | } | 85 | } | ||
| 78 | - | return tabName + " (" + paneTitle + ")" | 86 | + | return " (" + paneTitle + ")" |
| 79 | } | 87 | } | ||
| 80 | 88 | ||||
| 81 | // focusedPaneTitle returns the focused pane's display title for tab labels. | 89 | // focusedPaneTitle returns the focused pane's display title for tab labels. | ||
M internal/usecase/daemon/picker.go +19 -8
| @@ -72,15 +72,18 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 72 | 72 | curTab := 0 | |
| 73 | 73 | for _, s := range sessions { | |
| 74 | 74 | s.mu.Lock() | |
| 75 | - | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |
| 75 | + | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 76 | 76 | sessionAttention := false | |
| 77 | 77 | for i, tb := range s.tabs { | |
| 78 | - | label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle)) | |
| 78 | + | name := tabDisplayName(tb, i) | |
| 79 | + | view.Tabs[i] = picker.TabEntry{ | |
| 80 | + | Name: name, | |
| 81 | + | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | |
| 82 | + | Attention: tb.attention, | |
| 83 | + | } | |
| 79 | 84 | if tb.attention { | |
| 80 | - | label = attentionSuffix(label) | |
| 81 | 85 | sessionAttention = true | |
| 82 | 86 | } | |
| 83 | - | view.Tabs[i] = label | |
| 84 | 87 | } | |
| 85 | 88 | if sessionAttention { | |
| 86 | 89 | view.Name = attentionSuffix(view.Name) | |
| @@ -95,7 +98,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 95 | 98 | views = append(views, picker.SessionView{ | |
| 96 | 99 | ID: domain.SessionID("stopped:" + s.name), | |
| 97 | 100 | Name: s.name, | |
| 98 | - | Tabs: []string{""}, | |
| 101 | + | Tabs: []picker.TabEntry{{}}, | |
| 99 | 102 | Stopped: true, | |
| 100 | 103 | }) | |
| 101 | 104 | } | |
| @@ -103,7 +106,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 103 | 106 | } | |
| 104 | 107 | ||
| 105 | 108 | func attentionSuffix(label string) string { | |
| 106 | - | return label + " " + string(attentionGlyph) | |
| 109 | + | return label + " " + string(ui.AttentionGlyph) | |
| 107 | 110 | } | |
| 108 | 111 | ||
| 109 | 112 | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |
| @@ -496,8 +499,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 496 | 499 | ||
| 497 | 500 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 498 | 501 | styleSet := resolveThemeStyles(styles) | |
| 499 | - | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame { | |
| 500 | - | return model.Render(size, preview, styles...) | |
| 502 | + | renderStyles := picker.RenderStyles{ | |
| 503 | + | Selection: styleSet.selection, | |
| 504 | + | SelectionName: styleSet.pickerSelectionName, | |
| 505 | + | SelectionMuted: styleSet.pickerSelectionMuted, | |
| 506 | + | Name: styleSet.pickerName, | |
| 507 | + | Detail: styleSet.paletteDesc, | |
| 508 | + | Base: renderer.DefaultStyle(), | |
| 509 | + | } | |
| 510 | + | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 511 | + | return model.Render(size, preview, renderStyles) | |
| 501 | 512 | }) | |
| 502 | 513 | } | |
| 503 | 514 | ||
| @@ -72,15 +72,18 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 72 | curTab := 0 | 72 | curTab := 0 | ||
| 73 | for _, s := range sessions { | 73 | for _, s := range sessions { | ||
| 74 | s.mu.Lock() | 74 | s.mu.Lock() | ||
| 75 | - | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | 75 | + | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} |
| 76 | sessionAttention := false | 76 | sessionAttention := false | ||
| 77 | for i, tb := range s.tabs { | 77 | for i, tb := range s.tabs { | ||
| 78 | - | label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle)) | 78 | + | name := tabDisplayName(tb, i) |
| 79 | + | view.Tabs[i] = picker.TabEntry{ | |||
| 80 | + | Name: name, | |||
| 81 | + | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | |||
| 82 | + | Attention: tb.attention, | |||
| 83 | + | } | |||
| 79 | if tb.attention { | 84 | if tb.attention { | ||
| 80 | - | label = attentionSuffix(label) | |||
| 81 | sessionAttention = true | 85 | sessionAttention = true | ||
| 82 | } | 86 | } | ||
| 83 | - | view.Tabs[i] = label | |||
| 84 | } | 87 | } | ||
| 85 | if sessionAttention { | 88 | if sessionAttention { | ||
| 86 | view.Name = attentionSuffix(view.Name) | 89 | view.Name = attentionSuffix(view.Name) | ||
| @@ -95,7 +98,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 95 | views = append(views, picker.SessionView{ | 98 | views = append(views, picker.SessionView{ | ||
| 96 | ID: domain.SessionID("stopped:" + s.name), | 99 | ID: domain.SessionID("stopped:" + s.name), | ||
| 97 | Name: s.name, | 100 | Name: s.name, | ||
| 98 | - | Tabs: []string{""}, | 101 | + | Tabs: []picker.TabEntry{{}}, |
| 99 | Stopped: true, | 102 | Stopped: true, | ||
| 100 | }) | 103 | }) | ||
| 101 | } | 104 | } | ||
| @@ -103,7 +106,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 103 | } | 106 | } | ||
| 104 | 107 | ||||
| 105 | func attentionSuffix(label string) string { | 108 | func attentionSuffix(label string) string { | ||
| 106 | - | return label + " " + string(attentionGlyph) | 109 | + | return label + " " + string(ui.AttentionGlyph) |
| 107 | } | 110 | } | ||
| 108 | 111 | ||||
| 109 | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | 112 | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | ||
| @@ -496,8 +499,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||||
| 496 | 499 | ||||
| 497 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | 500 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | ||
| 498 | styleSet := resolveThemeStyles(styles) | 501 | styleSet := resolveThemeStyles(styles) | ||
| 499 | - | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame { | 502 | + | renderStyles := picker.RenderStyles{ |
| 500 | - | return model.Render(size, preview, styles...) | 503 | + | Selection: styleSet.selection, |
| 504 | + | SelectionName: styleSet.pickerSelectionName, | |||
| 505 | + | SelectionMuted: styleSet.pickerSelectionMuted, | |||
| 506 | + | Name: styleSet.pickerName, | |||
| 507 | + | Detail: styleSet.paletteDesc, | |||
| 508 | + | Base: renderer.DefaultStyle(), | |||
| 509 | + | } | |||
| 510 | + | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |||
| 511 | + | return model.Render(size, preview, renderStyles) | |||
| 501 | }) | 512 | }) | ||
| 502 | } | 513 | } | ||
| 503 | 514 | ||||
M internal/usecase/daemon/picker_test.go +4 -4
| @@ -61,9 +61,9 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 61 | 61 | require.Equal(t, 0, curTab) | |
| 62 | 62 | require.Len(t, views, 2) | |
| 63 | 63 | require.Equal(t, "alpha", views[0].Name) | |
| 64 | - | require.Equal(t, []string{"1", "2"}, views[0].Tabs) | |
| 64 | + | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | |
| 65 | 65 | require.Equal(t, "beta ", views[1].Name) | |
| 66 | - | require.Equal(t, []string{"shell", "logs "}, views[1].Tabs) | |
| 66 | + | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | 69 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |
| @@ -88,7 +88,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||
| 88 | 88 | views, _ := d.pickerViews(sess) | |
| 89 | 89 | ||
| 90 | 90 | require.Len(t, views, 1) | |
| 91 | - | require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs) | |
| 91 | + | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | 94 | func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |
| @@ -112,7 +112,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |||
| 112 | 112 | views, _ := d.pickerViews(sess) | |
| 113 | 113 | ||
| 114 | 114 | require.Len(t, views, 1) | |
| 115 | - | require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs) | |
| 115 | + | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs) | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | 118 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |
| @@ -61,9 +61,9 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 61 | require.Equal(t, 0, curTab) | 61 | require.Equal(t, 0, curTab) | ||
| 62 | require.Len(t, views, 2) | 62 | require.Len(t, views, 2) | ||
| 63 | require.Equal(t, "alpha", views[0].Name) | 63 | require.Equal(t, "alpha", views[0].Name) | ||
| 64 | - | require.Equal(t, []string{"1", "2"}, views[0].Tabs) | 64 | + | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) |
| 65 | require.Equal(t, "beta ", views[1].Name) | 65 | require.Equal(t, "beta ", views[1].Name) | ||
| 66 | - | require.Equal(t, []string{"shell", "logs "}, views[1].Tabs) | 66 | + | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) |
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | 69 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | ||
| @@ -88,7 +88,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||||
| 88 | views, _ := d.pickerViews(sess) | 88 | views, _ := d.pickerViews(sess) | ||
| 89 | 89 | ||||
| 90 | require.Len(t, views, 1) | 90 | require.Len(t, views, 1) | ||
| 91 | - | require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs) | 91 | + | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs) |
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | 94 | func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | ||
| @@ -112,7 +112,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |||||
| 112 | views, _ := d.pickerViews(sess) | 112 | views, _ := d.pickerViews(sess) | ||
| 113 | 113 | ||||
| 114 | require.Len(t, views, 1) | 114 | require.Len(t, views, 1) | ||
| 115 | - | require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs) | 115 | + | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs) |
| 116 | } | 116 | } | ||
| 117 | 117 | ||||
| 118 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | 118 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | ||
M internal/usecase/daemon/render.go +31 -5
| @@ -372,7 +372,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 372 | 372 | // without reading the live MRU, whose order may have changed mid-interaction. | |
| 373 | 373 | bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 374 | 374 | bars.theme = ac.getTheme() | |
| 375 | - | _, attentionVisible := pulseStyle(bars.attentionFrame) | |
| 375 | + | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 376 | 376 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 377 | 377 | ||
| 378 | 378 | styles := newThemeStyles(ac.getTheme()) | |
| @@ -582,16 +582,42 @@ type themeStyles struct { | |||
| 582 | 582 | selection renderer.Style | |
| 583 | 583 | copyStatus renderer.Style | |
| 584 | 584 | paletteDesc renderer.Style | |
| 585 | + | ||
| 586 | + | // Top bar tab label segments: name (bold) and parenthesized pane-title | |
| 587 | + | // (muted), one pair per base style. No-ops to statusBar/accent on | |
| 588 | + | // non-truecolor themes. | |
| 589 | + | tabName renderer.Style | |
| 590 | + | tabNameActive renderer.Style | |
| 591 | + | tabTitle renderer.Style | |
| 592 | + | tabTitleActive renderer.Style | |
| 593 | + | ||
| 594 | + | // Session picker row segments. Detail reuses paletteDesc (both are | |
| 595 | + | // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only. | |
| 596 | + | pickerName renderer.Style | |
| 597 | + | pickerSelectionName renderer.Style | |
| 598 | + | pickerSelectionMuted renderer.Style | |
| 585 | 599 | } | |
| 586 | 600 | ||
| 587 | 601 | func newThemeStyles(t themeui.Theme) themeStyles { | |
| 602 | + | statusBar := themeui.StatusBarStyle(t) | |
| 603 | + | accent := themeui.AccentStyle(t) | |
| 604 | + | selection := themeui.SelectionStyle(t) | |
| 588 | 605 | return themeStyles{ | |
| 589 | - | statusBar: themeui.StatusBarStyle(t), | |
| 590 | - | accent: themeui.AccentStyle(t), | |
| 606 | + | statusBar: statusBar, | |
| 607 | + | accent: accent, | |
| 591 | 608 | border: themeui.BorderStyle(t), | |
| 592 | - | selection: themeui.SelectionStyle(t), | |
| 593 | - | copyStatus: themeui.SelectionStyle(t), | |
| 609 | + | selection: selection, | |
| 610 | + | copyStatus: selection, | |
| 594 | 611 | paletteDesc: themeui.MutedTextStyle(t), | |
| 612 | + | ||
| 613 | + | tabName: themeui.EmphasisStyle(statusBar, t), | |
| 614 | + | tabNameActive: themeui.EmphasisStyle(accent, t), | |
| 615 | + | tabTitle: themeui.MutedVariantStyle(statusBar, t), | |
| 616 | + | tabTitleActive: themeui.MutedVariantStyle(accent, t), | |
| 617 | + | ||
| 618 | + | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |
| 619 | + | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |
| 620 | + | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |
| 595 | 621 | } | |
| 596 | 622 | } | |
| 597 | 623 | ||
| @@ -372,7 +372,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 372 | // without reading the live MRU, whose order may have changed mid-interaction. | 372 | // without reading the live MRU, whose order may have changed mid-interaction. | ||
| 373 | bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent) | 373 | bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent) | ||
| 374 | bars.theme = ac.getTheme() | 374 | bars.theme = ac.getTheme() | ||
| 375 | - | _, attentionVisible := pulseStyle(bars.attentionFrame) | 375 | + | attentionVisible := pulseVisible(bars.attentionFrame) |
| 376 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | 376 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | ||
| 377 | 377 | ||||
| 378 | styles := newThemeStyles(ac.getTheme()) | 378 | styles := newThemeStyles(ac.getTheme()) | ||
| @@ -582,16 +582,42 @@ type themeStyles struct { | |||||
| 582 | selection renderer.Style | 582 | selection renderer.Style | ||
| 583 | copyStatus renderer.Style | 583 | copyStatus renderer.Style | ||
| 584 | paletteDesc renderer.Style | 584 | paletteDesc renderer.Style | ||
| 585 | + | ||||
| 586 | + | // Top bar tab label segments: name (bold) and parenthesized pane-title | |||
| 587 | + | // (muted), one pair per base style. No-ops to statusBar/accent on | |||
| 588 | + | // non-truecolor themes. | |||
| 589 | + | tabName renderer.Style | |||
| 590 | + | tabNameActive renderer.Style | |||
| 591 | + | tabTitle renderer.Style | |||
| 592 | + | tabTitleActive renderer.Style | |||
| 593 | + | ||||
| 594 | + | // Session picker row segments. Detail reuses paletteDesc (both are | |||
| 595 | + | // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only. | |||
| 596 | + | pickerName renderer.Style | |||
| 597 | + | pickerSelectionName renderer.Style | |||
| 598 | + | pickerSelectionMuted renderer.Style | |||
| 585 | } | 599 | } | ||
| 586 | 600 | ||||
| 587 | func newThemeStyles(t themeui.Theme) themeStyles { | 601 | func newThemeStyles(t themeui.Theme) themeStyles { | ||
| 602 | + | statusBar := themeui.StatusBarStyle(t) | |||
| 603 | + | accent := themeui.AccentStyle(t) | |||
| 604 | + | selection := themeui.SelectionStyle(t) | |||
| 588 | return themeStyles{ | 605 | return themeStyles{ | ||
| 589 | - | statusBar: themeui.StatusBarStyle(t), | 606 | + | statusBar: statusBar, |
| 590 | - | accent: themeui.AccentStyle(t), | 607 | + | accent: accent, |
| 591 | border: themeui.BorderStyle(t), | 608 | border: themeui.BorderStyle(t), | ||
| 592 | - | selection: themeui.SelectionStyle(t), | 609 | + | selection: selection, |
| 593 | - | copyStatus: themeui.SelectionStyle(t), | 610 | + | copyStatus: selection, |
| 594 | paletteDesc: themeui.MutedTextStyle(t), | 611 | paletteDesc: themeui.MutedTextStyle(t), | ||
| 612 | + | ||||
| 613 | + | tabName: themeui.EmphasisStyle(statusBar, t), | |||
| 614 | + | tabNameActive: themeui.EmphasisStyle(accent, t), | |||
| 615 | + | tabTitle: themeui.MutedVariantStyle(statusBar, t), | |||
| 616 | + | tabTitleActive: themeui.MutedVariantStyle(accent, t), | |||
| 617 | + | ||||
| 618 | + | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |||
| 619 | + | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |||
| 620 | + | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |||
| 595 | } | 621 | } | ||
| 596 | } | 622 | } | ||
| 597 | 623 | ||||
M internal/usecase/daemon/status.go +66 -31
| @@ -20,8 +20,8 @@ | |||
| 20 | 20 | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | 21 | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | 22 | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it | |
| 24 | - | // never blocks on a slow client. | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 25 | 25 | package daemon | |
| 26 | 26 | ||
| 27 | 27 | import ( | |
| @@ -37,26 +37,40 @@ import ( | |||
| 37 | 37 | ) | |
| 38 | 38 | ||
| 39 | 39 | const ( | |
| 40 | - | attentionGlyph = '' | |
| 41 | 40 | maxMRUSessions = 9 | |
| 42 | 41 | pulseFrameCount = 30 | |
| 43 | 42 | pulseFrameInterval = 120 * time.Millisecond | |
| 44 | 43 | ) | |
| 45 | 44 | ||
| 46 | - | func pulseStyle(frame int) (renderer.Style, bool) { | |
| 47 | - | f := frame % pulseFrameCount | |
| 48 | - | if f == 0 { | |
| 49 | - | return renderer.DefaultStyle(), false | |
| 45 | + | // pulseVisible reports whether the attention bell glyph is showing (as | |
| 46 | + | // opposed to its blank beat) at frame. | |
| 47 | + | func pulseVisible(frame int) bool { | |
| 48 | + | return frame%pulseFrameCount != 0 | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // pulseStyle returns the style for the attention bell glyph at frame, built | |
| 52 | + | // on top of base so the bell always keeps the caller's background (the tab's | |
| 53 | + | // accent/statusBar, or a faded MRU entry's blended colors) instead of | |
| 54 | + | // punching a default-background hole in a themed bar. On the invisible beat | |
| 55 | + | // it returns (base, false) unchanged. | |
| 56 | + | func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |
| 57 | + | if !pulseVisible(frame) { | |
| 58 | + | return base, false | |
| 50 | 59 | } | |
| 60 | + | f := frame % pulseFrameCount | |
| 51 | 61 | peak := pulseFrameCount / 2 | |
| 52 | 62 | distance := f - peak | |
| 53 | 63 | if distance < 0 { | |
| 54 | 64 | distance = -distance | |
| 55 | 65 | } | |
| 56 | 66 | intensity := 1 - float64(distance)/float64(peak) | |
| 57 | - | style := renderer.DefaultStyle() | |
| 67 | + | style := base | |
| 58 | 68 | style.Bold = true | |
| 59 | - | style.Foreground = 244 + int(intensity*11) | |
| 69 | + | if base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 70 | + | style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |
| 71 | + | } else { | |
| 72 | + | style.Foreground = 244 + int(intensity*11) | |
| 73 | + | } | |
| 60 | 74 | return style, true | |
| 61 | 75 | } | |
| 62 | 76 | ||
| @@ -65,16 +79,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 65 | 79 | x := 0 | |
| 66 | 80 | labels := fitTabLabels(status.tabs, len(row), topRight) | |
| 67 | 81 | for i, w := range status.tabs { | |
| 68 | - | style := styles.statusBar | |
| 82 | + | baseStyle := styles.statusBar | |
| 83 | + | nameStyle := styles.tabName | |
| 84 | + | titleStyle := styles.tabTitle | |
| 69 | 85 | if w.active { | |
| 70 | - | style = styles.accent | |
| 86 | + | baseStyle = styles.accent | |
| 87 | + | nameStyle = styles.tabNameActive | |
| 88 | + | titleStyle = styles.tabTitleActive | |
| 71 | 89 | } | |
| 72 | - | writeStatusText(row, &x, " "+labels[i], style) | |
| 90 | + | label := labels[i] | |
| 91 | + | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| 73 | 92 | if w.attention { | |
| 74 | - | writeStatusText(row, &x, " ", style) | |
| 75 | - | writeBell(row, &x, frame) | |
| 93 | + | writeStatusText(row, &x, " ", baseStyle) | |
| 94 | + | writeBell(row, &x, frame, baseStyle) | |
| 76 | 95 | } | |
| 77 | - | writeStatusText(row, &x, " ", style) | |
| 96 | + | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 97 | + | writeStatusText(row, &x, " ", baseStyle) | |
| 78 | 98 | } | |
| 79 | 99 | drawRightPlainText(row, topRight, x, styles.statusBar) | |
| 80 | 100 | } | |
| @@ -121,7 +141,7 @@ func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent | |||
| 121 | 141 | writeStatusText(row, x, name, style) | |
| 122 | 142 | if sess.attention { | |
| 123 | 143 | writeStatusText(row, x, " ", style) | |
| 124 | - | writeBell(row, x, attentionFrame) | |
| 144 | + | writeBell(row, x, attentionFrame, style) | |
| 125 | 145 | } | |
| 126 | 146 | writeStatusText(row, x, " ", style) | |
| 127 | 147 | } | |
| @@ -133,7 +153,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, | |||
| 133 | 153 | writeStatusText(row, x, " "+name, style) | |
| 134 | 154 | if attention { | |
| 135 | 155 | writeStatusText(row, x, " ", style) | |
| 136 | - | writeBell(row, x, attentionFrame) | |
| 156 | + | writeBell(row, x, attentionFrame, style) | |
| 137 | 157 | } | |
| 138 | 158 | writeStatusText(row, x, " ", style) | |
| 139 | 159 | } | |
| @@ -285,9 +305,18 @@ func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState { | |||
| 285 | 305 | return state | |
| 286 | 306 | } | |
| 287 | 307 | ||
| 308 | + | // fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name | |
| 309 | + | // segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is | |
| 310 | + | // always a byte offset landing on a rune boundary, since it is derived from | |
| 311 | + | // the byte length of the (possibly truncated) name prefix. | |
| 312 | + | type fittedTabLabel struct { | |
| 313 | + | text string | |
| 314 | + | nameLen int | |
| 315 | + | } | |
| 316 | + | ||
| 288 | 317 | // fitTabLabels returns, per tab, the text drawn between its surrounding | |
| 289 | 318 | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | |
| 290 | - | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |
| 319 | + | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel { | |
| 291 | 320 | reserve := 1 | |
| 292 | 321 | if rightText != "" { | |
| 293 | 322 | reserve = statusTextWidth(rightText) + 2 | |
| @@ -302,15 +331,17 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||
| 302 | 331 | full[i] = composeTabTitle(t.name, t.paneTitle) | |
| 303 | 332 | o := 2 | |
| 304 | 333 | if t.attention { | |
| 305 | - | o += 1 + renderer.RuneWidth(attentionGlyph) | |
| 334 | + | o += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |
| 306 | 335 | } | |
| 307 | 336 | overhead[i] = o | |
| 308 | 337 | widths[i] = statusTextWidth(full[i]) | |
| 309 | 338 | total += o + widths[i] | |
| 310 | 339 | } | |
| 311 | - | labels := make([]string, len(tabs)) | |
| 340 | + | labels := make([]fittedTabLabel, len(tabs)) | |
| 312 | 341 | if total <= budget { | |
| 313 | - | copy(labels, full) | |
| 342 | + | for i, t := range tabs { | |
| 343 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)} | |
| 344 | + | } | |
| 314 | 345 | return labels | |
| 315 | 346 | } | |
| 316 | 347 | ||
| @@ -332,13 +363,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||
| 332 | 363 | textBudget := share - overhead[i] | |
| 333 | 364 | switch { | |
| 334 | 365 | case textBudget <= 0: | |
| 335 | - | labels[i] = "" | |
| 366 | + | labels[i] = fittedTabLabel{} | |
| 336 | 367 | case widths[i] <= textBudget: | |
| 337 | - | labels[i] = full[i] | |
| 368 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)} | |
| 338 | 369 | case textBudget >= statusTextWidth(tabs[i].name)+4: | |
| 339 | - | labels[i] = ui.TruncateText(full[i], textBudget) | |
| 370 | + | labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)} | |
| 340 | 371 | default: | |
| 341 | - | labels[i] = ui.TruncateText(tabs[i].name, textBudget) | |
| 372 | + | text := ui.TruncateText(tabs[i].name, textBudget) | |
| 373 | + | labels[i] = fittedTabLabel{text: text, nameLen: len(text)} | |
| 342 | 374 | } | |
| 343 | 375 | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | |
| 344 | 376 | remaining -= consumed | |
| @@ -363,7 +395,7 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri | |||
| 363 | 395 | } | |
| 364 | 396 | width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name) | |
| 365 | 397 | if e.attention { | |
| 366 | - | width += 1 + renderer.RuneWidth(attentionGlyph) | |
| 398 | + | width += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |
| 367 | 399 | } | |
| 368 | 400 | return width | |
| 369 | 401 | } | |
| @@ -395,7 +427,7 @@ func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []re | |||
| 395 | 427 | } | |
| 396 | 428 | n := 2 + statusTextWidth(name) | |
| 397 | 429 | if e.attention { | |
| 398 | - | n += 1 + renderer.RuneWidth(attentionGlyph) | |
| 430 | + | n += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |
| 399 | 431 | } | |
| 400 | 432 | return n | |
| 401 | 433 | } | |
| @@ -453,13 +485,16 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style | |||
| 453 | 485 | return base | |
| 454 | 486 | } | |
| 455 | 487 | ||
| 456 | - | func writeBell(row []renderer.Cell, x *int, frame int) { | |
| 457 | - | style, visible := pulseStyle(frame) | |
| 488 | + | // writeBell draws the attention glyph on its visible beat, or a plain cell in | |
| 489 | + | // base's style on its blank beat, so the bell never leaves a bare | |
| 490 | + | // default-background hole in a themed bar. | |
| 491 | + | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) { | |
| 492 | + | style, visible := pulseStyle(frame, base) | |
| 458 | 493 | if !visible { | |
| 459 | - | writeStatusText(row, x, " ", renderer.DefaultStyle()) | |
| 494 | + | writeStatusText(row, x, " ", base) | |
| 460 | 495 | return | |
| 461 | 496 | } | |
| 462 | - | writeStatusText(row, x, string(attentionGlyph), style) | |
| 497 | + | writeStatusText(row, x, string(ui.AttentionGlyph), style) | |
| 463 | 498 | } | |
| 464 | 499 | ||
| 465 | 500 | func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) { | |
| @@ -20,8 +20,8 @@ | |||||
| 20 | // Locking: a pane's screen/scrollback and per-client renderer shadow are | 20 | // Locking: a pane's screen/scrollback and per-client renderer shadow are | ||
| 21 | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | 21 | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | ||
| 22 | // session.mu; the registry by Daemon.mu. When more than one is held the order | 22 | // session.mu; the registry by Daemon.mu. When more than one is held the order | ||
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // never blocks on a slow client. | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | package daemon | 25 | package daemon | ||
| 26 | 26 | ||||
| 27 | import ( | 27 | import ( | ||
| @@ -37,26 +37,40 @@ import ( | |||||
| 37 | ) | 37 | ) | ||
| 38 | 38 | ||||
| 39 | const ( | 39 | const ( | ||
| 40 | - | attentionGlyph = '' | |||
| 41 | maxMRUSessions = 9 | 40 | maxMRUSessions = 9 | ||
| 42 | pulseFrameCount = 30 | 41 | pulseFrameCount = 30 | ||
| 43 | pulseFrameInterval = 120 * time.Millisecond | 42 | pulseFrameInterval = 120 * time.Millisecond | ||
| 44 | ) | 43 | ) | ||
| 45 | 44 | ||||
| 46 | - | func pulseStyle(frame int) (renderer.Style, bool) { | 45 | + | // pulseVisible reports whether the attention bell glyph is showing (as |
| 47 | - | f := frame % pulseFrameCount | 46 | + | // opposed to its blank beat) at frame. |
| 48 | - | if f == 0 { | 47 | + | func pulseVisible(frame int) bool { |
| 49 | - | return renderer.DefaultStyle(), false | 48 | + | return frame%pulseFrameCount != 0 |
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // pulseStyle returns the style for the attention bell glyph at frame, built | |||
| 52 | + | // on top of base so the bell always keeps the caller's background (the tab's | |||
| 53 | + | // accent/statusBar, or a faded MRU entry's blended colors) instead of | |||
| 54 | + | // punching a default-background hole in a themed bar. On the invisible beat | |||
| 55 | + | // it returns (base, false) unchanged. | |||
| 56 | + | func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |||
| 57 | + | if !pulseVisible(frame) { | |||
| 58 | + | return base, false | |||
| 50 | } | 59 | } | ||
| 60 | + | f := frame % pulseFrameCount | |||
| 51 | peak := pulseFrameCount / 2 | 61 | peak := pulseFrameCount / 2 | ||
| 52 | distance := f - peak | 62 | distance := f - peak | ||
| 53 | if distance < 0 { | 63 | if distance < 0 { | ||
| 54 | distance = -distance | 64 | distance = -distance | ||
| 55 | } | 65 | } | ||
| 56 | intensity := 1 - float64(distance)/float64(peak) | 66 | intensity := 1 - float64(distance)/float64(peak) | ||
| 57 | - | style := renderer.DefaultStyle() | 67 | + | style := base |
| 58 | style.Bold = true | 68 | style.Bold = true | ||
| 59 | - | style.Foreground = 244 + int(intensity*11) | 69 | + | if base.HasForegroundRGB && base.HasBackgroundRGB { |
| 70 | + | style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |||
| 71 | + | } else { | |||
| 72 | + | style.Foreground = 244 + int(intensity*11) | |||
| 73 | + | } | |||
| 60 | return style, true | 74 | return style, true | ||
| 61 | } | 75 | } | ||
| 62 | 76 | ||||
| @@ -65,16 +79,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||||
| 65 | x := 0 | 79 | x := 0 | ||
| 66 | labels := fitTabLabels(status.tabs, len(row), topRight) | 80 | labels := fitTabLabels(status.tabs, len(row), topRight) | ||
| 67 | for i, w := range status.tabs { | 81 | for i, w := range status.tabs { | ||
| 68 | - | style := styles.statusBar | 82 | + | baseStyle := styles.statusBar |
| 83 | + | nameStyle := styles.tabName | |||
| 84 | + | titleStyle := styles.tabTitle | |||
| 69 | if w.active { | 85 | if w.active { | ||
| 70 | - | style = styles.accent | 86 | + | baseStyle = styles.accent |
| 87 | + | nameStyle = styles.tabNameActive | |||
| 88 | + | titleStyle = styles.tabTitleActive | |||
| 71 | } | 89 | } | ||
| 72 | - | writeStatusText(row, &x, " "+labels[i], style) | 90 | + | label := labels[i] |
| 91 | + | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |||
| 73 | if w.attention { | 92 | if w.attention { | ||
| 74 | - | writeStatusText(row, &x, " ", style) | 93 | + | writeStatusText(row, &x, " ", baseStyle) |
| 75 | - | writeBell(row, &x, frame) | 94 | + | writeBell(row, &x, frame, baseStyle) |
| 76 | } | 95 | } | ||
| 77 | - | writeStatusText(row, &x, " ", style) | 96 | + | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) |
| 97 | + | writeStatusText(row, &x, " ", baseStyle) | |||
| 78 | } | 98 | } | ||
| 79 | drawRightPlainText(row, topRight, x, styles.statusBar) | 99 | drawRightPlainText(row, topRight, x, styles.statusBar) | ||
| 80 | } | 100 | } | ||
| @@ -121,7 +141,7 @@ func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent | |||||
| 121 | writeStatusText(row, x, name, style) | 141 | writeStatusText(row, x, name, style) | ||
| 122 | if sess.attention { | 142 | if sess.attention { | ||
| 123 | writeStatusText(row, x, " ", style) | 143 | writeStatusText(row, x, " ", style) | ||
| 124 | - | writeBell(row, x, attentionFrame) | 144 | + | writeBell(row, x, attentionFrame, style) |
| 125 | } | 145 | } | ||
| 126 | writeStatusText(row, x, " ", style) | 146 | writeStatusText(row, x, " ", style) | ||
| 127 | } | 147 | } | ||
| @@ -133,7 +153,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, | |||||
| 133 | writeStatusText(row, x, " "+name, style) | 153 | writeStatusText(row, x, " "+name, style) | ||
| 134 | if attention { | 154 | if attention { | ||
| 135 | writeStatusText(row, x, " ", style) | 155 | writeStatusText(row, x, " ", style) | ||
| 136 | - | writeBell(row, x, attentionFrame) | 156 | + | writeBell(row, x, attentionFrame, style) |
| 137 | } | 157 | } | ||
| 138 | writeStatusText(row, x, " ", style) | 158 | writeStatusText(row, x, " ", style) | ||
| 139 | } | 159 | } | ||
| @@ -285,9 +305,18 @@ func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState { | |||||
| 285 | return state | 305 | return state | ||
| 286 | } | 306 | } | ||
| 287 | 307 | ||||
| 308 | + | // fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name | |||
| 309 | + | // segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is | |||
| 310 | + | // always a byte offset landing on a rune boundary, since it is derived from | |||
| 311 | + | // the byte length of the (possibly truncated) name prefix. | |||
| 312 | + | type fittedTabLabel struct { | |||
| 313 | + | text string | |||
| 314 | + | nameLen int | |||
| 315 | + | } | |||
| 316 | + | ||||
| 288 | // fitTabLabels returns, per tab, the text drawn between its surrounding | 317 | // fitTabLabels returns, per tab, the text drawn between its surrounding | ||
| 289 | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | 318 | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | ||
| 290 | - | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | 319 | + | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel { |
| 291 | reserve := 1 | 320 | reserve := 1 | ||
| 292 | if rightText != "" { | 321 | if rightText != "" { | ||
| 293 | reserve = statusTextWidth(rightText) + 2 | 322 | reserve = statusTextWidth(rightText) + 2 | ||
| @@ -302,15 +331,17 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||||
| 302 | full[i] = composeTabTitle(t.name, t.paneTitle) | 331 | full[i] = composeTabTitle(t.name, t.paneTitle) | ||
| 303 | o := 2 | 332 | o := 2 | ||
| 304 | if t.attention { | 333 | if t.attention { | ||
| 305 | - | o += 1 + renderer.RuneWidth(attentionGlyph) | 334 | + | o += 1 + renderer.RuneWidth(ui.AttentionGlyph) |
| 306 | } | 335 | } | ||
| 307 | overhead[i] = o | 336 | overhead[i] = o | ||
| 308 | widths[i] = statusTextWidth(full[i]) | 337 | widths[i] = statusTextWidth(full[i]) | ||
| 309 | total += o + widths[i] | 338 | total += o + widths[i] | ||
| 310 | } | 339 | } | ||
| 311 | - | labels := make([]string, len(tabs)) | 340 | + | labels := make([]fittedTabLabel, len(tabs)) |
| 312 | if total <= budget { | 341 | if total <= budget { | ||
| 313 | - | copy(labels, full) | 342 | + | for i, t := range tabs { |
| 343 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)} | |||
| 344 | + | } | |||
| 314 | return labels | 345 | return labels | ||
| 315 | } | 346 | } | ||
| 316 | 347 | ||||
| @@ -332,13 +363,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||||
| 332 | textBudget := share - overhead[i] | 363 | textBudget := share - overhead[i] | ||
| 333 | switch { | 364 | switch { | ||
| 334 | case textBudget <= 0: | 365 | case textBudget <= 0: | ||
| 335 | - | labels[i] = "" | 366 | + | labels[i] = fittedTabLabel{} |
| 336 | case widths[i] <= textBudget: | 367 | case widths[i] <= textBudget: | ||
| 337 | - | labels[i] = full[i] | 368 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)} |
| 338 | case textBudget >= statusTextWidth(tabs[i].name)+4: | 369 | case textBudget >= statusTextWidth(tabs[i].name)+4: | ||
| 339 | - | labels[i] = ui.TruncateText(full[i], textBudget) | 370 | + | labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)} |
| 340 | default: | 371 | default: | ||
| 341 | - | labels[i] = ui.TruncateText(tabs[i].name, textBudget) | 372 | + | text := ui.TruncateText(tabs[i].name, textBudget) |
| 373 | + | labels[i] = fittedTabLabel{text: text, nameLen: len(text)} | |||
| 342 | } | 374 | } | ||
| 343 | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | 375 | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | ||
| 344 | remaining -= consumed | 376 | remaining -= consumed | ||
| @@ -363,7 +395,7 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri | |||||
| 363 | } | 395 | } | ||
| 364 | width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name) | 396 | width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name) | ||
| 365 | if e.attention { | 397 | if e.attention { | ||
| 366 | - | width += 1 + renderer.RuneWidth(attentionGlyph) | 398 | + | width += 1 + renderer.RuneWidth(ui.AttentionGlyph) |
| 367 | } | 399 | } | ||
| 368 | return width | 400 | return width | ||
| 369 | } | 401 | } | ||
| @@ -395,7 +427,7 @@ func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []re | |||||
| 395 | } | 427 | } | ||
| 396 | n := 2 + statusTextWidth(name) | 428 | n := 2 + statusTextWidth(name) | ||
| 397 | if e.attention { | 429 | if e.attention { | ||
| 398 | - | n += 1 + renderer.RuneWidth(attentionGlyph) | 430 | + | n += 1 + renderer.RuneWidth(ui.AttentionGlyph) |
| 399 | } | 431 | } | ||
| 400 | return n | 432 | return n | ||
| 401 | } | 433 | } | ||
| @@ -453,13 +485,16 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style | |||||
| 453 | return base | 485 | return base | ||
| 454 | } | 486 | } | ||
| 455 | 487 | ||||
| 456 | - | func writeBell(row []renderer.Cell, x *int, frame int) { | 488 | + | // writeBell draws the attention glyph on its visible beat, or a plain cell in |
| 457 | - | style, visible := pulseStyle(frame) | 489 | + | // base's style on its blank beat, so the bell never leaves a bare |
| 490 | + | // default-background hole in a themed bar. | |||
| 491 | + | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) { | |||
| 492 | + | style, visible := pulseStyle(frame, base) | |||
| 458 | if !visible { | 493 | if !visible { | ||
| 459 | - | writeStatusText(row, x, " ", renderer.DefaultStyle()) | 494 | + | writeStatusText(row, x, " ", base) |
| 460 | return | 495 | return | ||
| 461 | } | 496 | } | ||
| 462 | - | writeStatusText(row, x, string(attentionGlyph), style) | 497 | + | writeStatusText(row, x, string(ui.AttentionGlyph), style) |
| 463 | } | 498 | } | ||
| 464 | 499 | ||||
| 465 | func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) { | 500 | func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) { | ||
M internal/usecase/daemon/status_test.go +193 -22
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | "testing" | |
| 9 | 9 | "time" | |
| 10 | + | "unicode/utf8" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/stretchr/testify/require" | |
| 12 | 13 | ||
| @@ -16,6 +17,7 @@ import ( | |||
| 16 | 17 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/palette" | |
| 18 | 19 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 19 | 21 | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| @@ -164,19 +166,19 @@ func TestFitTabLabels(t *testing.T) { | |||
| 164 | 166 | tabs []statusTab | |
| 165 | 167 | rowLen int | |
| 166 | 168 | rightText string | |
| 167 | - | want []string | |
| 169 | + | want []fittedTabLabel | |
| 168 | 170 | }{ | |
| 169 | 171 | { | |
| 170 | 172 | name: "no tabs", | |
| 171 | 173 | tabs: nil, | |
| 172 | 174 | rowLen: 10, | |
| 173 | - | want: []string{}, | |
| 175 | + | want: []fittedTabLabel{}, | |
| 174 | 176 | }, | |
| 175 | 177 | { | |
| 176 | 178 | name: "everything fits stays untruncated", | |
| 177 | 179 | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | |
| 178 | 180 | rowLen: 40, | |
| 179 | - | want: []string{"one", "two (shell)"}, | |
| 181 | + | want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}}, | |
| 180 | 182 | }, | |
| 181 | 183 | { | |
| 182 | 184 | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | |
| @@ -185,26 +187,26 @@ func TestFitTabLabels(t *testing.T) { | |||
| 185 | 187 | {name: "ed", paneTitle: "verylongtitle"}, | |
| 186 | 188 | }, | |
| 187 | 189 | rowLen: 20, | |
| 188 | - | want: []string{"a", "ed (verylongt…"}, | |
| 190 | + | want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}}, | |
| 189 | 191 | }, | |
| 190 | 192 | { | |
| 191 | 193 | name: "tight budget degrades to a truncated tab name only", | |
| 192 | 194 | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | |
| 193 | 195 | rowLen: 7, | |
| 194 | - | want: []string{"edi…"}, | |
| 196 | + | want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}}, | |
| 195 | 197 | }, | |
| 196 | 198 | { | |
| 197 | 199 | name: "non-empty rightText shrinks the budget", | |
| 198 | 200 | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | |
| 199 | 201 | rowLen: 20, | |
| 200 | 202 | rightText: "0123456789", | |
| 201 | - | want: []string{"aaaaa…"}, | |
| 203 | + | want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}}, | |
| 202 | 204 | }, | |
| 203 | 205 | { | |
| 204 | 206 | name: "single tab wider than the whole row collapses to empty", | |
| 205 | 207 | tabs: []statusTab{{name: "reallyverylongtabname"}}, | |
| 206 | 208 | rowLen: 3, | |
| 207 | - | want: []string{""}, | |
| 209 | + | want: []fittedTabLabel{{}}, | |
| 208 | 210 | }, | |
| 209 | 211 | } | |
| 210 | 212 | for _, tt := range tests { | |
| @@ -222,9 +224,13 @@ func TestFitTabLabels(t *testing.T) { | |||
| 222 | 224 | for i, tb := range tt.tabs { | |
| 223 | 225 | overhead := 2 | |
| 224 | 226 | if tb.attention { | |
| 225 | - | overhead += 1 + renderer.RuneWidth(attentionGlyph) | |
| 227 | + | overhead += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |
| 228 | + | } | |
| 229 | + | drawn += overhead + statusTextWidth(got[i].text) | |
| 230 | + | require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length") | |
| 231 | + | if got[i].nameLen < len(got[i].text) { | |
| 232 | + | require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary") | |
| 226 | 233 | } | |
| 227 | - | drawn += overhead + statusTextWidth(got[i]) | |
| 228 | 234 | } | |
| 229 | 235 | if budget > 0 { | |
| 230 | 236 | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | |
| @@ -233,6 +239,141 @@ func TestFitTabLabels(t *testing.T) { | |||
| 233 | 239 | } | |
| 234 | 240 | } | |
| 235 | 241 | ||
| 242 | + | func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |
| 243 | + | tabs := []statusTab{ | |
| 244 | + | {name: "api", paneTitle: "shell"}, | |
| 245 | + | {name: "logs", active: true, attention: true}, | |
| 246 | + | {name: "err", paneTitle: "build failed", attention: true}, | |
| 247 | + | } | |
| 248 | + | status := statusSnapshot{tabs: tabs} | |
| 249 | + | const rowLen = 60 | |
| 250 | + | ||
| 251 | + | t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) { | |
| 252 | + | theme := themeui.Theme{ | |
| 253 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 254 | + | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 255 | + | HasFG: true, | |
| 256 | + | HasBG: true, | |
| 257 | + | Known: true, | |
| 258 | + | TrueColor: true, | |
| 259 | + | } | |
| 260 | + | styles := newThemeStyles(theme) | |
| 261 | + | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |
| 262 | + | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | |
| 263 | + | ||
| 264 | + | labels := fitTabLabels(tabs, rowLen, "") | |
| 265 | + | ||
| 266 | + | // Frame 0 is the bell's blank beat: the attention space and the blank | |
| 267 | + | // bell cell must both be exactly the tab's base style (not | |
| 268 | + | // renderer.DefaultStyle(), which would punch a hole in a themed bar). | |
| 269 | + | // This also pins down segment order: name, then (if attention) | |
| 270 | + | // space+bell, then title, then the trailing space. | |
| 271 | + | row := make([]renderer.Cell, rowLen) | |
| 272 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |
| 273 | + | x := 0 | |
| 274 | + | ||
| 275 | + | // tab 0: inactive, no attention: name, title, trailing space. | |
| 276 | + | nameEnd := x + 1 + labels[0].nameLen | |
| 277 | + | for i := x; i < nameEnd; i++ { | |
| 278 | + | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | |
| 279 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | |
| 280 | + | } | |
| 281 | + | x = nameEnd | |
| 282 | + | titleLen := len(labels[0].text) - labels[0].nameLen | |
| 283 | + | titleEnd := x + titleLen | |
| 284 | + | for i := x; i < titleEnd; i++ { | |
| 285 | + | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | |
| 286 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | |
| 287 | + | } | |
| 288 | + | x = titleEnd | |
| 289 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x) | |
| 290 | + | x++ | |
| 291 | + | ||
| 292 | + | // tab 1: active, attention, no title: name, attention space+bell (both | |
| 293 | + | // base style at frame 0), trailing space. | |
| 294 | + | nameEnd = x + 1 + labels[1].nameLen | |
| 295 | + | for i := x; i < nameEnd; i++ { | |
| 296 | + | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | |
| 297 | + | require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i) | |
| 298 | + | } | |
| 299 | + | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | |
| 300 | + | x = nameEnd | |
| 301 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x) | |
| 302 | + | x++ | |
| 303 | + | tab1Bell := x | |
| 304 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) | |
| 305 | + | x++ | |
| 306 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x) | |
| 307 | + | x++ | |
| 308 | + | ||
| 309 | + | // tab 2: inactive, attention, with title: name, attention space+bell | |
| 310 | + | // (both base style at frame 0), title, trailing space. | |
| 311 | + | nameEnd = x + 1 + labels[2].nameLen | |
| 312 | + | for i := x; i < nameEnd; i++ { | |
| 313 | + | require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i) | |
| 314 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i) | |
| 315 | + | } | |
| 316 | + | x = nameEnd | |
| 317 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x) | |
| 318 | + | x++ | |
| 319 | + | tab2Bell := x | |
| 320 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) | |
| 321 | + | x++ | |
| 322 | + | titleLen = len(labels[2].text) - labels[2].nameLen | |
| 323 | + | titleEnd = x + titleLen | |
| 324 | + | for i := x; i < titleEnd; i++ { | |
| 325 | + | require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i) | |
| 326 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i) | |
| 327 | + | } | |
| 328 | + | x = titleEnd | |
| 329 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x) | |
| 330 | + | ||
| 331 | + | // On a visible pulse frame, the bell glyph itself must keep the same | |
| 332 | + | // background as its tab's base style. | |
| 333 | + | visibleRow := make([]renderer.Cell, rowLen) | |
| 334 | + | drawTopBarSnapshot(visibleRow, status, 1, "", styles) | |
| 335 | + | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune) | |
| 336 | + | require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB) | |
| 337 | + | require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") | |
| 338 | + | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune) | |
| 339 | + | require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB) | |
| 340 | + | require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") | |
| 341 | + | }) | |
| 342 | + | ||
| 343 | + | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | |
| 344 | + | styles := resolveThemeStyles(nil) | |
| 345 | + | require.True(t, styles.tabName.Equal(styles.statusBar)) | |
| 346 | + | require.True(t, styles.tabTitle.Equal(styles.statusBar)) | |
| 347 | + | require.True(t, styles.tabNameActive.Equal(styles.accent)) | |
| 348 | + | require.True(t, styles.tabTitleActive.Equal(styles.accent)) | |
| 349 | + | ||
| 350 | + | row := make([]renderer.Cell, rowLen) | |
| 351 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |
| 352 | + | ||
| 353 | + | // " api (shell) " (tab 0) + " logs " (tab 1: name, attention | |
| 354 | + | // space+blank bell, trailing space) + " err (build failed) " (tab 2: | |
| 355 | + | // name, attention space+blank bell, title, trailing space), then blank | |
| 356 | + | // padding to rowLen: byte-for-byte what the pre-change renderer | |
| 357 | + | // produced, since every new style field collapses to statusBar/accent. | |
| 358 | + | const tab0 = " api (shell) " | |
| 359 | + | const tab1 = " logs " | |
| 360 | + | const tab2 = " err (build failed) " | |
| 361 | + | require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row)) | |
| 362 | + | ||
| 363 | + | for i, c := range row[:len(tab0)] { | |
| 364 | + | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | |
| 365 | + | } | |
| 366 | + | tab1Start := len(tab0) | |
| 367 | + | for i := tab1Start; i < tab1Start+len(tab1); i++ { | |
| 368 | + | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i) | |
| 369 | + | } | |
| 370 | + | tab2Start := tab1Start + len(tab1) | |
| 371 | + | for i := tab2Start; i < tab2Start+len(tab2); i++ { | |
| 372 | + | require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i) | |
| 373 | + | } | |
| 374 | + | }) | |
| 375 | + | } | |
| 376 | + | ||
| 236 | 377 | func TestTabDisplayName(t *testing.T) { | |
| 237 | 378 | tests := []struct { | |
| 238 | 379 | name string | |
| @@ -722,20 +863,49 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |||
| 722 | 863 | } | |
| 723 | 864 | ||
| 724 | 865 | func TestAttentionConstants(t *testing.T) { | |
| 725 | - | require.Equal(t, '', attentionGlyph) | |
| 866 | + | require.Equal(t, '', ui.AttentionGlyph) | |
| 726 | 867 | require.Equal(t, 30, pulseFrameCount) | |
| 727 | 868 | require.Equal(t, 120*time.Millisecond, pulseFrameInterval) | |
| 728 | 869 | } | |
| 729 | 870 | ||
| 730 | - | func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) { | |
| 731 | - | _, visible := pulseStyle(0) | |
| 732 | - | require.False(t, visible) | |
| 871 | + | func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) { | |
| 872 | + | rgbBase := renderer.Style{ | |
| 873 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 874 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 875 | + | } | |
| 876 | + | indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true} | |
| 877 | + | ||
| 878 | + | t.Run("invisible frame returns base unchanged", func(t *testing.T) { | |
| 879 | + | for _, base := range []renderer.Style{rgbBase, indexedBase} { | |
| 880 | + | style, visible := pulseStyle(0, base) | |
| 881 | + | require.False(t, visible) | |
| 882 | + | require.True(t, style.Equal(base)) | |
| 883 | + | } | |
| 884 | + | }) | |
| 885 | + | ||
| 886 | + | t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) { | |
| 887 | + | low, visible := pulseStyle(1, rgbBase) | |
| 888 | + | require.True(t, visible) | |
| 889 | + | require.True(t, low.Bold) | |
| 890 | + | require.True(t, low.HasBackgroundRGB) | |
| 891 | + | require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar") | |
| 892 | + | ||
| 893 | + | peak, visible := pulseStyle(pulseFrameCount/2, rgbBase) | |
| 894 | + | require.True(t, visible) | |
| 895 | + | require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly") | |
| 896 | + | require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity") | |
| 897 | + | }) | |
| 898 | + | ||
| 899 | + | t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) { | |
| 900 | + | low, visible := pulseStyle(1, indexedBase) | |
| 901 | + | require.True(t, visible) | |
| 902 | + | require.True(t, low.Bold) | |
| 903 | + | require.True(t, low.Inverse, "non-color base attributes like inverse must survive") | |
| 733 | 904 | ||
| 734 | - | low, visible := pulseStyle(1) | |
| 735 | - | require.True(t, visible) | |
| 736 | - | peak, visible := pulseStyle(pulseFrameCount / 2) | |
| 737 | - | require.True(t, visible) | |
| 738 | - | require.Greater(t, peak.Foreground, low.Foreground) | |
| 905 | + | peak, visible := pulseStyle(pulseFrameCount/2, indexedBase) | |
| 906 | + | require.True(t, visible) | |
| 907 | + | require.Greater(t, peak.Foreground, low.Foreground) | |
| 908 | + | }) | |
| 739 | 909 | } | |
| 740 | 910 | ||
| 741 | 911 | func TestTopBarRendersAttentionBell(t *testing.T) { | |
| @@ -755,10 +925,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 755 | 925 | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) | |
| 756 | 926 | ||
| 757 | 927 | // Tab 2's label is enriched with its focused pane's title ("sh", the | |
| 758 | - | // default shell fallback) and truncated to fit alongside the bell. | |
| 759 | - | require.Contains(t, rowText(frame.Row(0)), "2 (s") | |
| 928 | + | // default shell fallback) and truncated to fit alongside the bell. The | |
| 929 | + | // bell sits right after the name, before the (title) segment. | |
| 930 | + | require.Contains(t, rowText(frame.Row(0)), "2 "+string(ui.AttentionGlyph)+" (s") | |
| 760 | 931 | for _, c := range frame.Row(0) { | |
| 761 | - | if c.Rune == attentionGlyph { | |
| 932 | + | if c.Rune == ui.AttentionGlyph { | |
| 762 | 933 | require.True(t, c.Style.Bold) | |
| 763 | 934 | return | |
| 764 | 935 | } | |
| @@ -879,7 +1050,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) { | |||
| 879 | 1050 | ||
| 880 | 1051 | require.Equal(t, " cur fresh tmp* ", rowText(row)) | |
| 881 | 1052 | for _, c := range row { | |
| 882 | - | if c.Rune == attentionGlyph { | |
| 1053 | + | if c.Rune == ui.AttentionGlyph { | |
| 883 | 1054 | require.True(t, c.Style.Bold) | |
| 884 | 1055 | return | |
| 885 | 1056 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "sync" | 7 | "sync" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | + | "unicode/utf8" | |||
| 10 | 11 | ||||
| 11 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 12 | 13 | ||||
| @@ -16,6 +17,7 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/usecase/layout" | 17 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 17 | "github.com/bnema/vev/internal/usecase/palette" | 18 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | 19 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 20 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 19 | "github.com/bnema/vev/pkg/renderer" | 21 | "github.com/bnema/vev/pkg/renderer" | ||
| 20 | ) | 22 | ) | ||
| 21 | 23 | ||||
| @@ -164,19 +166,19 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 164 | tabs []statusTab | 166 | tabs []statusTab | ||
| 165 | rowLen int | 167 | rowLen int | ||
| 166 | rightText string | 168 | rightText string | ||
| 167 | - | want []string | 169 | + | want []fittedTabLabel |
| 168 | }{ | 170 | }{ | ||
| 169 | { | 171 | { | ||
| 170 | name: "no tabs", | 172 | name: "no tabs", | ||
| 171 | tabs: nil, | 173 | tabs: nil, | ||
| 172 | rowLen: 10, | 174 | rowLen: 10, | ||
| 173 | - | want: []string{}, | 175 | + | want: []fittedTabLabel{}, |
| 174 | }, | 176 | }, | ||
| 175 | { | 177 | { | ||
| 176 | name: "everything fits stays untruncated", | 178 | name: "everything fits stays untruncated", | ||
| 177 | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | 179 | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | ||
| 178 | rowLen: 40, | 180 | rowLen: 40, | ||
| 179 | - | want: []string{"one", "two (shell)"}, | 181 | + | want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}}, |
| 180 | }, | 182 | }, | ||
| 181 | { | 183 | { | ||
| 182 | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | 184 | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | ||
| @@ -185,26 +187,26 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 185 | {name: "ed", paneTitle: "verylongtitle"}, | 187 | {name: "ed", paneTitle: "verylongtitle"}, | ||
| 186 | }, | 188 | }, | ||
| 187 | rowLen: 20, | 189 | rowLen: 20, | ||
| 188 | - | want: []string{"a", "ed (verylongt…"}, | 190 | + | want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}}, |
| 189 | }, | 191 | }, | ||
| 190 | { | 192 | { | ||
| 191 | name: "tight budget degrades to a truncated tab name only", | 193 | name: "tight budget degrades to a truncated tab name only", | ||
| 192 | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | 194 | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | ||
| 193 | rowLen: 7, | 195 | rowLen: 7, | ||
| 194 | - | want: []string{"edi…"}, | 196 | + | want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}}, |
| 195 | }, | 197 | }, | ||
| 196 | { | 198 | { | ||
| 197 | name: "non-empty rightText shrinks the budget", | 199 | name: "non-empty rightText shrinks the budget", | ||
| 198 | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | 200 | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | ||
| 199 | rowLen: 20, | 201 | rowLen: 20, | ||
| 200 | rightText: "0123456789", | 202 | rightText: "0123456789", | ||
| 201 | - | want: []string{"aaaaa…"}, | 203 | + | want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}}, |
| 202 | }, | 204 | }, | ||
| 203 | { | 205 | { | ||
| 204 | name: "single tab wider than the whole row collapses to empty", | 206 | name: "single tab wider than the whole row collapses to empty", | ||
| 205 | tabs: []statusTab{{name: "reallyverylongtabname"}}, | 207 | tabs: []statusTab{{name: "reallyverylongtabname"}}, | ||
| 206 | rowLen: 3, | 208 | rowLen: 3, | ||
| 207 | - | want: []string{""}, | 209 | + | want: []fittedTabLabel{{}}, |
| 208 | }, | 210 | }, | ||
| 209 | } | 211 | } | ||
| 210 | for _, tt := range tests { | 212 | for _, tt := range tests { | ||
| @@ -222,9 +224,13 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 222 | for i, tb := range tt.tabs { | 224 | for i, tb := range tt.tabs { | ||
| 223 | overhead := 2 | 225 | overhead := 2 | ||
| 224 | if tb.attention { | 226 | if tb.attention { | ||
| 225 | - | overhead += 1 + renderer.RuneWidth(attentionGlyph) | 227 | + | overhead += 1 + renderer.RuneWidth(ui.AttentionGlyph) |
| 228 | + | } | |||
| 229 | + | drawn += overhead + statusTextWidth(got[i].text) | |||
| 230 | + | require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length") | |||
| 231 | + | if got[i].nameLen < len(got[i].text) { | |||
| 232 | + | require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary") | |||
| 226 | } | 233 | } | ||
| 227 | - | drawn += overhead + statusTextWidth(got[i]) | |||
| 228 | } | 234 | } | ||
| 229 | if budget > 0 { | 235 | if budget > 0 { | ||
| 230 | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | 236 | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | ||
| @@ -233,6 +239,141 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 233 | } | 239 | } | ||
| 234 | } | 240 | } | ||
| 235 | 241 | ||||
| 242 | + | func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 243 | + | tabs := []statusTab{ | |||
| 244 | + | {name: "api", paneTitle: "shell"}, | |||
| 245 | + | {name: "logs", active: true, attention: true}, | |||
| 246 | + | {name: "err", paneTitle: "build failed", attention: true}, | |||
| 247 | + | } | |||
| 248 | + | status := statusSnapshot{tabs: tabs} | |||
| 249 | + | const rowLen = 60 | |||
| 250 | + | ||||
| 251 | + | t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) { | |||
| 252 | + | theme := themeui.Theme{ | |||
| 253 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 254 | + | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |||
| 255 | + | HasFG: true, | |||
| 256 | + | HasBG: true, | |||
| 257 | + | Known: true, | |||
| 258 | + | TrueColor: true, | |||
| 259 | + | } | |||
| 260 | + | styles := newThemeStyles(theme) | |||
| 261 | + | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |||
| 262 | + | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | |||
| 263 | + | ||||
| 264 | + | labels := fitTabLabels(tabs, rowLen, "") | |||
| 265 | + | ||||
| 266 | + | // Frame 0 is the bell's blank beat: the attention space and the blank | |||
| 267 | + | // bell cell must both be exactly the tab's base style (not | |||
| 268 | + | // renderer.DefaultStyle(), which would punch a hole in a themed bar). | |||
| 269 | + | // This also pins down segment order: name, then (if attention) | |||
| 270 | + | // space+bell, then title, then the trailing space. | |||
| 271 | + | row := make([]renderer.Cell, rowLen) | |||
| 272 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |||
| 273 | + | x := 0 | |||
| 274 | + | ||||
| 275 | + | // tab 0: inactive, no attention: name, title, trailing space. | |||
| 276 | + | nameEnd := x + 1 + labels[0].nameLen | |||
| 277 | + | for i := x; i < nameEnd; i++ { | |||
| 278 | + | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | |||
| 279 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | |||
| 280 | + | } | |||
| 281 | + | x = nameEnd | |||
| 282 | + | titleLen := len(labels[0].text) - labels[0].nameLen | |||
| 283 | + | titleEnd := x + titleLen | |||
| 284 | + | for i := x; i < titleEnd; i++ { | |||
| 285 | + | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | |||
| 286 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | |||
| 287 | + | } | |||
| 288 | + | x = titleEnd | |||
| 289 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x) | |||
| 290 | + | x++ | |||
| 291 | + | ||||
| 292 | + | // tab 1: active, attention, no title: name, attention space+bell (both | |||
| 293 | + | // base style at frame 0), trailing space. | |||
| 294 | + | nameEnd = x + 1 + labels[1].nameLen | |||
| 295 | + | for i := x; i < nameEnd; i++ { | |||
| 296 | + | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | |||
| 297 | + | require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i) | |||
| 298 | + | } | |||
| 299 | + | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | |||
| 300 | + | x = nameEnd | |||
| 301 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x) | |||
| 302 | + | x++ | |||
| 303 | + | tab1Bell := x | |||
| 304 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) | |||
| 305 | + | x++ | |||
| 306 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x) | |||
| 307 | + | x++ | |||
| 308 | + | ||||
| 309 | + | // tab 2: inactive, attention, with title: name, attention space+bell | |||
| 310 | + | // (both base style at frame 0), title, trailing space. | |||
| 311 | + | nameEnd = x + 1 + labels[2].nameLen | |||
| 312 | + | for i := x; i < nameEnd; i++ { | |||
| 313 | + | require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i) | |||
| 314 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i) | |||
| 315 | + | } | |||
| 316 | + | x = nameEnd | |||
| 317 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x) | |||
| 318 | + | x++ | |||
| 319 | + | tab2Bell := x | |||
| 320 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) | |||
| 321 | + | x++ | |||
| 322 | + | titleLen = len(labels[2].text) - labels[2].nameLen | |||
| 323 | + | titleEnd = x + titleLen | |||
| 324 | + | for i := x; i < titleEnd; i++ { | |||
| 325 | + | require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i) | |||
| 326 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i) | |||
| 327 | + | } | |||
| 328 | + | x = titleEnd | |||
| 329 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x) | |||
| 330 | + | ||||
| 331 | + | // On a visible pulse frame, the bell glyph itself must keep the same | |||
| 332 | + | // background as its tab's base style. | |||
| 333 | + | visibleRow := make([]renderer.Cell, rowLen) | |||
| 334 | + | drawTopBarSnapshot(visibleRow, status, 1, "", styles) | |||
| 335 | + | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune) | |||
| 336 | + | require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB) | |||
| 337 | + | require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") | |||
| 338 | + | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune) | |||
| 339 | + | require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB) | |||
| 340 | + | require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") | |||
| 341 | + | }) | |||
| 342 | + | ||||
| 343 | + | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | |||
| 344 | + | styles := resolveThemeStyles(nil) | |||
| 345 | + | require.True(t, styles.tabName.Equal(styles.statusBar)) | |||
| 346 | + | require.True(t, styles.tabTitle.Equal(styles.statusBar)) | |||
| 347 | + | require.True(t, styles.tabNameActive.Equal(styles.accent)) | |||
| 348 | + | require.True(t, styles.tabTitleActive.Equal(styles.accent)) | |||
| 349 | + | ||||
| 350 | + | row := make([]renderer.Cell, rowLen) | |||
| 351 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |||
| 352 | + | ||||
| 353 | + | // " api (shell) " (tab 0) + " logs " (tab 1: name, attention | |||
| 354 | + | // space+blank bell, trailing space) + " err (build failed) " (tab 2: | |||
| 355 | + | // name, attention space+blank bell, title, trailing space), then blank | |||
| 356 | + | // padding to rowLen: byte-for-byte what the pre-change renderer | |||
| 357 | + | // produced, since every new style field collapses to statusBar/accent. | |||
| 358 | + | const tab0 = " api (shell) " | |||
| 359 | + | const tab1 = " logs " | |||
| 360 | + | const tab2 = " err (build failed) " | |||
| 361 | + | require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row)) | |||
| 362 | + | ||||
| 363 | + | for i, c := range row[:len(tab0)] { | |||
| 364 | + | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | |||
| 365 | + | } | |||
| 366 | + | tab1Start := len(tab0) | |||
| 367 | + | for i := tab1Start; i < tab1Start+len(tab1); i++ { | |||
| 368 | + | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i) | |||
| 369 | + | } | |||
| 370 | + | tab2Start := tab1Start + len(tab1) | |||
| 371 | + | for i := tab2Start; i < tab2Start+len(tab2); i++ { | |||
| 372 | + | require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i) | |||
| 373 | + | } | |||
| 374 | + | }) | |||
| 375 | + | } | |||
| 376 | + | ||||
| 236 | func TestTabDisplayName(t *testing.T) { | 377 | func TestTabDisplayName(t *testing.T) { | ||
| 237 | tests := []struct { | 378 | tests := []struct { | ||
| 238 | name string | 379 | name string | ||
| @@ -722,20 +863,49 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |||||
| 722 | } | 863 | } | ||
| 723 | 864 | ||||
| 724 | func TestAttentionConstants(t *testing.T) { | 865 | func TestAttentionConstants(t *testing.T) { | ||
| 725 | - | require.Equal(t, '', attentionGlyph) | 866 | + | require.Equal(t, '', ui.AttentionGlyph) |
| 726 | require.Equal(t, 30, pulseFrameCount) | 867 | require.Equal(t, 30, pulseFrameCount) | ||
| 727 | require.Equal(t, 120*time.Millisecond, pulseFrameInterval) | 868 | require.Equal(t, 120*time.Millisecond, pulseFrameInterval) | ||
| 728 | } | 869 | } | ||
| 729 | 870 | ||||
| 730 | - | func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) { | 871 | + | func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) { |
| 731 | - | _, visible := pulseStyle(0) | 872 | + | rgbBase := renderer.Style{ |
| 732 | - | require.False(t, visible) | 873 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200}, |
| 874 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30}, | |||
| 875 | + | } | |||
| 876 | + | indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true} | |||
| 877 | + | ||||
| 878 | + | t.Run("invisible frame returns base unchanged", func(t *testing.T) { | |||
| 879 | + | for _, base := range []renderer.Style{rgbBase, indexedBase} { | |||
| 880 | + | style, visible := pulseStyle(0, base) | |||
| 881 | + | require.False(t, visible) | |||
| 882 | + | require.True(t, style.Equal(base)) | |||
| 883 | + | } | |||
| 884 | + | }) | |||
| 885 | + | ||||
| 886 | + | t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) { | |||
| 887 | + | low, visible := pulseStyle(1, rgbBase) | |||
| 888 | + | require.True(t, visible) | |||
| 889 | + | require.True(t, low.Bold) | |||
| 890 | + | require.True(t, low.HasBackgroundRGB) | |||
| 891 | + | require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar") | |||
| 892 | + | ||||
| 893 | + | peak, visible := pulseStyle(pulseFrameCount/2, rgbBase) | |||
| 894 | + | require.True(t, visible) | |||
| 895 | + | require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly") | |||
| 896 | + | require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity") | |||
| 897 | + | }) | |||
| 898 | + | ||||
| 899 | + | t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) { | |||
| 900 | + | low, visible := pulseStyle(1, indexedBase) | |||
| 901 | + | require.True(t, visible) | |||
| 902 | + | require.True(t, low.Bold) | |||
| 903 | + | require.True(t, low.Inverse, "non-color base attributes like inverse must survive") | |||
| 733 | 904 | ||||
| 734 | - | low, visible := pulseStyle(1) | 905 | + | peak, visible := pulseStyle(pulseFrameCount/2, indexedBase) |
| 735 | - | require.True(t, visible) | 906 | + | require.True(t, visible) |
| 736 | - | peak, visible := pulseStyle(pulseFrameCount / 2) | 907 | + | require.Greater(t, peak.Foreground, low.Foreground) |
| 737 | - | require.True(t, visible) | 908 | + | }) |
| 738 | - | require.Greater(t, peak.Foreground, low.Foreground) | |||
| 739 | } | 909 | } | ||
| 740 | 910 | ||||
| 741 | func TestTopBarRendersAttentionBell(t *testing.T) { | 911 | func TestTopBarRendersAttentionBell(t *testing.T) { | ||
| @@ -755,10 +925,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||||
| 755 | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) | 925 | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) | ||
| 756 | 926 | ||||
| 757 | // Tab 2's label is enriched with its focused pane's title ("sh", the | 927 | // Tab 2's label is enriched with its focused pane's title ("sh", the | ||
| 758 | - | // default shell fallback) and truncated to fit alongside the bell. | 928 | + | // default shell fallback) and truncated to fit alongside the bell. The |
| 759 | - | require.Contains(t, rowText(frame.Row(0)), "2 (s") | 929 | + | // bell sits right after the name, before the (title) segment. |
| 930 | + | require.Contains(t, rowText(frame.Row(0)), "2 "+string(ui.AttentionGlyph)+" (s") | |||
| 760 | for _, c := range frame.Row(0) { | 931 | for _, c := range frame.Row(0) { | ||
| 761 | - | if c.Rune == attentionGlyph { | 932 | + | if c.Rune == ui.AttentionGlyph { |
| 762 | require.True(t, c.Style.Bold) | 933 | require.True(t, c.Style.Bold) | ||
| 763 | return | 934 | return | ||
| 764 | } | 935 | } | ||
| @@ -879,7 +1050,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) { | |||||
| 879 | 1050 | ||||
| 880 | require.Equal(t, " cur fresh tmp* ", rowText(row)) | 1051 | require.Equal(t, " cur fresh tmp* ", rowText(row)) | ||
| 881 | for _, c := range row { | 1052 | for _, c := range row { | ||
| 882 | - | if c.Rune == attentionGlyph { | 1053 | + | if c.Rune == ui.AttentionGlyph { |
| 883 | require.True(t, c.Style.Bold) | 1054 | require.True(t, c.Style.Bold) | ||
| 884 | return | 1055 | return | ||
| 885 | } | 1056 | } | ||
M internal/usecase/picker/model.go +77 -29
| @@ -17,11 +17,38 @@ const ( | |||
| 17 | 17 | type SessionView struct { | |
| 18 | 18 | ID domain.SessionID | |
| 19 | 19 | Name string | |
| 20 | - | Tabs []string | |
| 20 | + | Tabs []TabEntry | |
| 21 | 21 | Active int | |
| 22 | 22 | Stopped bool | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | + | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | |
| 26 | + | type TabEntry struct { | |
| 27 | + | Name string // tab display name | |
| 28 | + | Detail string // " (paneTitle)" or "", drawn muted | |
| 29 | + | Attention bool // draw the attention marker right after Name, before Detail | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // RenderStyles are the styles Render uses to draw list rows. The zero value | |
| 33 | + | // (all renderer.Style{}) is never used directly; Render falls back to | |
| 34 | + | // defaultRenderStyles when no RenderStyles is supplied, mirroring the styling | |
| 35 | + | // of a non-truecolor client. | |
| 36 | + | type RenderStyles struct { | |
| 37 | + | Selection renderer.Style // selected row fill + suffixes | |
| 38 | + | SelectionName renderer.Style // selected row name segment | |
| 39 | + | SelectionMuted renderer.Style // selected row detail segment | |
| 40 | + | Name renderer.Style // non-selected name segment | |
| 41 | + | Detail renderer.Style // non-selected detail segment | |
| 42 | + | Base renderer.Style // non-selected fill + suffixes | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func defaultRenderStyles() RenderStyles { | |
| 46 | + | selection := renderer.DefaultStyle() | |
| 47 | + | selection.Inverse = true | |
| 48 | + | base := renderer.DefaultStyle() | |
| 49 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base} | |
| 50 | + | } | |
| 51 | + | ||
| 25 | 52 | type Target struct { | |
| 26 | 53 | Session domain.SessionID | |
| 27 | 54 | Name string | |
| @@ -46,30 +73,34 @@ type Model struct { | |||
| 46 | 73 | } | |
| 47 | 74 | ||
| 48 | 75 | type row struct { | |
| 49 | - | header bool | |
| 50 | - | label string | |
| 51 | - | session domain.SessionID | |
| 52 | - | name string | |
| 53 | - | tabIndex int | |
| 54 | - | stopped bool | |
| 76 | + | header bool | |
| 77 | + | dispName string // display name segment; bold on truecolor | |
| 78 | + | detail string // " (paneTitle)" segment, muted; "" for headers | |
| 79 | + | attention bool // draw the attention marker right after the name, before detail; tab rows only | |
| 80 | + | session domain.SessionID | |
| 81 | + | // targetName is the stopped-session lookup name threaded into Target; | |
| 82 | + | // distinct from dispName, which is what gets drawn. | |
| 83 | + | targetName string | |
| 84 | + | tabIndex int | |
| 85 | + | stopped bool | |
| 55 | 86 | } | |
| 56 | 87 | ||
| 57 | 88 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |
| 58 | 89 | m := &Model{selected: -1} | |
| 59 | 90 | activeSelection := -1 | |
| 60 | 91 | for _, session := range sessions { | |
| 61 | - | name := "" | |
| 92 | + | targetName := "" | |
| 62 | 93 | if session.Stopped { | |
| 63 | - | name = session.Name | |
| 94 | + | targetName = session.Name | |
| 64 | 95 | } | |
| 65 | - | m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped}) | |
| 96 | + | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped}) | |
| 66 | 97 | active := session.Active | |
| 67 | 98 | if active < 0 || active >= len(session.Tabs) { | |
| 68 | 99 | active = 0 | |
| 69 | 100 | } | |
| 70 | 101 | for i, tab := range session.Tabs { | |
| 71 | 102 | idx := len(m.rows) | |
| 72 | - | m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped}) | |
| 103 | + | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped}) | |
| 73 | 104 | if session.ID == cur && i == curTab { | |
| 74 | 105 | m.selected = idx | |
| 75 | 106 | } | |
| @@ -131,7 +162,7 @@ func (m *Model) Selected() (Target, bool) { | |||
| 131 | 162 | if r.header { | |
| 132 | 163 | return Target{}, false | |
| 133 | 164 | } | |
| 134 | - | return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true | |
| 165 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true | |
| 135 | 166 | } | |
| 136 | 167 | ||
| 137 | 168 | func (m *Model) Clone() *Model { | |
| @@ -143,15 +174,14 @@ func (m *Model) Clone() *Model { | |||
| 143 | 174 | return &clone | |
| 144 | 175 | } | |
| 145 | 176 | ||
| 146 | - | func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame { | |
| 177 | + | func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame { | |
| 147 | 178 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 148 | 179 | layout := ChooseLayout(inner) | |
| 149 | - | selection := renderer.DefaultStyle() | |
| 150 | - | selection.Inverse = true | |
| 151 | - | if len(selectedStyle) > 0 { | |
| 152 | - | selection = selectedStyle[0] | |
| 180 | + | styleSet := defaultRenderStyles() | |
| 181 | + | if len(styles) > 0 { | |
| 182 | + | styleSet = styles[0] | |
| 153 | 183 | } | |
| 154 | - | m.renderList(frame, layout.List, selection) | |
| 184 | + | m.renderList(frame, layout.List, styleSet) | |
| 155 | 185 | blitPreview(frame, layout.Preview, preview) | |
| 156 | 186 | return frame | |
| 157 | 187 | } | |
| @@ -181,32 +211,50 @@ func (m *Model) firstLeaf() int { | |||
| 181 | 211 | return -1 | |
| 182 | 212 | } | |
| 183 | 213 | ||
| 184 | - | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) { | |
| 214 | + | // renderList draws each visible row as up to three segments: a name segment | |
| 215 | + | // (bold when styles came from a truecolor theme), a base-styled attention | |
| 216 | + | // marker right after the name, and a muted detail segment (tab rows only) — | |
| 217 | + | // or a base-styled "(stopped)" suffix for stopped session headers. A tight | |
| 218 | + | // width ellipsizes the detail segment before eating into the name. | |
| 219 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | |
| 185 | 220 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 186 | 221 | return | |
| 187 | 222 | } | |
| 188 | 223 | visible := min(rect.Height, frame.Height-rect.Y) | |
| 189 | 224 | offset := m.scrollOffset(visible) | |
| 225 | + | clipX := rect.X + rect.Width | |
| 190 | 226 | for y := range visible { | |
| 191 | 227 | idx := offset + y | |
| 192 | 228 | if idx >= len(m.rows) { | |
| 193 | 229 | break | |
| 194 | 230 | } | |
| 195 | 231 | r := m.rows[idx] | |
| 196 | - | style := renderer.DefaultStyle() | |
| 232 | + | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | |
| 197 | 233 | if idx == m.selected { | |
| 198 | - | style = selection | |
| 199 | - | } | |
| 200 | - | label := r.label | |
| 201 | - | if r.header && r.stopped { | |
| 202 | - | label += " (stopped)" | |
| 234 | + | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | |
| 203 | 235 | } | |
| 236 | + | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |
| 237 | + | ||
| 238 | + | name := r.dispName | |
| 204 | 239 | if !r.header { | |
| 205 | - | label = " " + label | |
| 240 | + | name = " " + name | |
| 206 | 241 | } | |
| 207 | - | label = ui.TruncateText(label, rect.Width) | |
| 208 | - | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 209 | - | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | |
| 242 | + | name = ui.TruncateText(name, rect.Width) | |
| 243 | + | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | |
| 244 | + | ||
| 245 | + | if r.header { | |
| 246 | + | if r.stopped { | |
| 247 | + | ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base) | |
| 248 | + | } | |
| 249 | + | continue | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | if r.attention { | |
| 253 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | detail := ui.TruncateText(r.detail, clipX-x) | |
| 257 | + | ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |
| 210 | 258 | } | |
| 211 | 259 | } | |
| 212 | 260 | ||
| @@ -17,11 +17,38 @@ const ( | |||||
| 17 | type SessionView struct { | 17 | type SessionView struct { | ||
| 18 | ID domain.SessionID | 18 | ID domain.SessionID | ||
| 19 | Name string | 19 | Name string | ||
| 20 | - | Tabs []string | 20 | + | Tabs []TabEntry |
| 21 | Active int | 21 | Active int | ||
| 22 | Stopped bool | 22 | Stopped bool | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | + | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | |||
| 26 | + | type TabEntry struct { | |||
| 27 | + | Name string // tab display name | |||
| 28 | + | Detail string // " (paneTitle)" or "", drawn muted | |||
| 29 | + | Attention bool // draw the attention marker right after Name, before Detail | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // RenderStyles are the styles Render uses to draw list rows. The zero value | |||
| 33 | + | // (all renderer.Style{}) is never used directly; Render falls back to | |||
| 34 | + | // defaultRenderStyles when no RenderStyles is supplied, mirroring the styling | |||
| 35 | + | // of a non-truecolor client. | |||
| 36 | + | type RenderStyles struct { | |||
| 37 | + | Selection renderer.Style // selected row fill + suffixes | |||
| 38 | + | SelectionName renderer.Style // selected row name segment | |||
| 39 | + | SelectionMuted renderer.Style // selected row detail segment | |||
| 40 | + | Name renderer.Style // non-selected name segment | |||
| 41 | + | Detail renderer.Style // non-selected detail segment | |||
| 42 | + | Base renderer.Style // non-selected fill + suffixes | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func defaultRenderStyles() RenderStyles { | |||
| 46 | + | selection := renderer.DefaultStyle() | |||
| 47 | + | selection.Inverse = true | |||
| 48 | + | base := renderer.DefaultStyle() | |||
| 49 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base} | |||
| 50 | + | } | |||
| 51 | + | ||||
| 25 | type Target struct { | 52 | type Target struct { | ||
| 26 | Session domain.SessionID | 53 | Session domain.SessionID | ||
| 27 | Name string | 54 | Name string | ||
| @@ -46,30 +73,34 @@ type Model struct { | |||||
| 46 | } | 73 | } | ||
| 47 | 74 | ||||
| 48 | type row struct { | 75 | type row struct { | ||
| 49 | - | header bool | 76 | + | header bool |
| 50 | - | label string | 77 | + | dispName string // display name segment; bold on truecolor |
| 51 | - | session domain.SessionID | 78 | + | detail string // " (paneTitle)" segment, muted; "" for headers |
| 52 | - | name string | 79 | + | attention bool // draw the attention marker right after the name, before detail; tab rows only |
| 53 | - | tabIndex int | 80 | + | session domain.SessionID |
| 54 | - | stopped bool | 81 | + | // targetName is the stopped-session lookup name threaded into Target; |
| 82 | + | // distinct from dispName, which is what gets drawn. | |||
| 83 | + | targetName string | |||
| 84 | + | tabIndex int | |||
| 85 | + | stopped bool | |||
| 55 | } | 86 | } | ||
| 56 | 87 | ||||
| 57 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | 88 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | ||
| 58 | m := &Model{selected: -1} | 89 | m := &Model{selected: -1} | ||
| 59 | activeSelection := -1 | 90 | activeSelection := -1 | ||
| 60 | for _, session := range sessions { | 91 | for _, session := range sessions { | ||
| 61 | - | name := "" | 92 | + | targetName := "" |
| 62 | if session.Stopped { | 93 | if session.Stopped { | ||
| 63 | - | name = session.Name | 94 | + | targetName = session.Name |
| 64 | } | 95 | } | ||
| 65 | - | m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped}) | 96 | + | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped}) |
| 66 | active := session.Active | 97 | active := session.Active | ||
| 67 | if active < 0 || active >= len(session.Tabs) { | 98 | if active < 0 || active >= len(session.Tabs) { | ||
| 68 | active = 0 | 99 | active = 0 | ||
| 69 | } | 100 | } | ||
| 70 | for i, tab := range session.Tabs { | 101 | for i, tab := range session.Tabs { | ||
| 71 | idx := len(m.rows) | 102 | idx := len(m.rows) | ||
| 72 | - | m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped}) | 103 | + | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped}) |
| 73 | if session.ID == cur && i == curTab { | 104 | if session.ID == cur && i == curTab { | ||
| 74 | m.selected = idx | 105 | m.selected = idx | ||
| 75 | } | 106 | } | ||
| @@ -131,7 +162,7 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 131 | if r.header { | 162 | if r.header { | ||
| 132 | return Target{}, false | 163 | return Target{}, false | ||
| 133 | } | 164 | } | ||
| 134 | - | return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true | 165 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true |
| 135 | } | 166 | } | ||
| 136 | 167 | ||||
| 137 | func (m *Model) Clone() *Model { | 168 | func (m *Model) Clone() *Model { | ||
| @@ -143,15 +174,14 @@ func (m *Model) Clone() *Model { | |||||
| 143 | return &clone | 174 | return &clone | ||
| 144 | } | 175 | } | ||
| 145 | 176 | ||||
| 146 | - | func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame { | 177 | + | func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame { |
| 147 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 178 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 148 | layout := ChooseLayout(inner) | 179 | layout := ChooseLayout(inner) | ||
| 149 | - | selection := renderer.DefaultStyle() | 180 | + | styleSet := defaultRenderStyles() |
| 150 | - | selection.Inverse = true | 181 | + | if len(styles) > 0 { |
| 151 | - | if len(selectedStyle) > 0 { | 182 | + | styleSet = styles[0] |
| 152 | - | selection = selectedStyle[0] | |||
| 153 | } | 183 | } | ||
| 154 | - | m.renderList(frame, layout.List, selection) | 184 | + | m.renderList(frame, layout.List, styleSet) |
| 155 | blitPreview(frame, layout.Preview, preview) | 185 | blitPreview(frame, layout.Preview, preview) | ||
| 156 | return frame | 186 | return frame | ||
| 157 | } | 187 | } | ||
| @@ -181,32 +211,50 @@ func (m *Model) firstLeaf() int { | |||||
| 181 | return -1 | 211 | return -1 | ||
| 182 | } | 212 | } | ||
| 183 | 213 | ||||
| 184 | - | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) { | 214 | + | // renderList draws each visible row as up to three segments: a name segment |
| 215 | + | // (bold when styles came from a truecolor theme), a base-styled attention | |||
| 216 | + | // marker right after the name, and a muted detail segment (tab rows only) — | |||
| 217 | + | // or a base-styled "(stopped)" suffix for stopped session headers. A tight | |||
| 218 | + | // width ellipsizes the detail segment before eating into the name. | |||
| 219 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | |||
| 185 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | 220 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | ||
| 186 | return | 221 | return | ||
| 187 | } | 222 | } | ||
| 188 | visible := min(rect.Height, frame.Height-rect.Y) | 223 | visible := min(rect.Height, frame.Height-rect.Y) | ||
| 189 | offset := m.scrollOffset(visible) | 224 | offset := m.scrollOffset(visible) | ||
| 225 | + | clipX := rect.X + rect.Width | |||
| 190 | for y := range visible { | 226 | for y := range visible { | ||
| 191 | idx := offset + y | 227 | idx := offset + y | ||
| 192 | if idx >= len(m.rows) { | 228 | if idx >= len(m.rows) { | ||
| 193 | break | 229 | break | ||
| 194 | } | 230 | } | ||
| 195 | r := m.rows[idx] | 231 | r := m.rows[idx] | ||
| 196 | - | style := renderer.DefaultStyle() | 232 | + | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail |
| 197 | if idx == m.selected { | 233 | if idx == m.selected { | ||
| 198 | - | style = selection | 234 | + | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted |
| 199 | - | } | |||
| 200 | - | label := r.label | |||
| 201 | - | if r.header && r.stopped { | |||
| 202 | - | label += " (stopped)" | |||
| 203 | } | 235 | } | ||
| 236 | + | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |||
| 237 | + | ||||
| 238 | + | name := r.dispName | |||
| 204 | if !r.header { | 239 | if !r.header { | ||
| 205 | - | label = " " + label | 240 | + | name = " " + name |
| 206 | } | 241 | } | ||
| 207 | - | label = ui.TruncateText(label, rect.Width) | 242 | + | name = ui.TruncateText(name, rect.Width) |
| 208 | - | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | 243 | + | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) |
| 209 | - | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | 244 | + | |
| 245 | + | if r.header { | |||
| 246 | + | if r.stopped { | |||
| 247 | + | ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base) | |||
| 248 | + | } | |||
| 249 | + | continue | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | if r.attention { | |||
| 253 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | detail := ui.TruncateText(r.detail, clipX-x) | |||
| 257 | + | ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |||
| 210 | } | 258 | } | ||
| 211 | } | 259 | } | ||
| 212 | 260 | ||||
M internal/usecase/picker/model_test.go +86 -13
| @@ -4,14 +4,15 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 7 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |
| 12 | 13 | m := New([]SessionView{ | |
| 13 | - | {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0}, | |
| 14 | - | {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0}, | |
| 14 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0}, | |
| 15 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0}, | |
| 15 | 16 | }, "s1", 1) | |
| 16 | 17 | ||
| 17 | 18 | got, ok := m.Selected() | |
| @@ -25,12 +26,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |||
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |
| 28 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0) | |
| 29 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0) | |
| 29 | 30 | got, ok := m.Selected() | |
| 30 | 31 | require.True(t, ok) | |
| 31 | 32 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |
| 32 | 33 | ||
| 33 | - | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0) | |
| 34 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0) | |
| 34 | 35 | got, ok = m.Selected() | |
| 35 | 36 | require.True(t, ok) | |
| 36 | 37 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | |
| @@ -38,8 +39,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |||
| 38 | 39 | ||
| 39 | 40 | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |
| 40 | 41 | m := New([]SessionView{ | |
| 41 | - | {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0}, | |
| 42 | - | {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0}, | |
| 42 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0}, | |
| 43 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0}, | |
| 43 | 44 | }, "s1", 0) | |
| 44 | 45 | ||
| 45 | 46 | m.Up() | |
| @@ -92,8 +93,8 @@ func TestChooseLayoutBoundaries(t *testing.T) { | |||
| 92 | 93 | ||
| 93 | 94 | func TestSelectedMapping(t *testing.T) { | |
| 94 | 95 | m := New([]SessionView{ | |
| 95 | - | {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0}, | |
| 96 | - | {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0}, | |
| 96 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |
| 97 | + | {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0}, | |
| 97 | 98 | }, "beta", 1) | |
| 98 | 99 | ||
| 99 | 100 | got, ok := m.Selected() | |
| @@ -102,7 +103,7 @@ func TestSelectedMapping(t *testing.T) { | |||
| 102 | 103 | } | |
| 103 | 104 | ||
| 104 | 105 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |
| 105 | - | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0) | |
| 106 | + | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0) | |
| 106 | 107 | got, ok := m.Selected() | |
| 107 | 108 | require.True(t, ok) | |
| 108 | 109 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | |
| @@ -112,7 +113,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||
| 112 | 113 | } | |
| 113 | 114 | ||
| 114 | 115 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 115 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 116 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 116 | 117 | preview := Preview{ | |
| 117 | 118 | Width: 24, | |
| 118 | 119 | Height: 1, | |
| @@ -132,7 +133,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||
| 132 | 133 | } | |
| 133 | 134 | ||
| 134 | 135 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |
| 135 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0) | |
| 136 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0) | |
| 136 | 137 | for range 5 { | |
| 137 | 138 | m.Down() | |
| 138 | 139 | } | |
| @@ -144,7 +145,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 144 | 145 | } | |
| 145 | 146 | ||
| 146 | 147 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |
| 147 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0) | |
| 148 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) | |
| 148 | 149 | ||
| 149 | 150 | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 150 | 151 | ||
| @@ -155,7 +156,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | 158 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| 158 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 159 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 159 | 160 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | |
| 160 | 161 | ||
| 161 | 162 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | |
| @@ -164,6 +165,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |||
| 164 | 165 | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 168 | + | func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |
| 169 | + | nameStyle := renderer.Style{Bold: true} | |
| 170 | + | detailStyle := renderer.Style{Italic: true} | |
| 171 | + | baseStyle := renderer.DefaultStyle() | |
| 172 | + | selectionStyle := renderer.Style{Inverse: true} | |
| 173 | + | selectionNameStyle := renderer.Style{Inverse: true, Bold: true} | |
| 174 | + | selectionMutedStyle := renderer.Style{Inverse: true, Italic: true} | |
| 175 | + | styles := RenderStyles{ | |
| 176 | + | Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle, | |
| 177 | + | Name: nameStyle, Detail: detailStyle, Base: baseStyle, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 181 | + | {Name: "alpha", Detail: " (running)"}, | |
| 182 | + | {Name: "beta", Detail: " (idle)", Attention: true}, | |
| 183 | + | }, Active: 0}}, "s1", 0) | |
| 184 | + | ||
| 185 | + | frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles) | |
| 186 | + | ||
| 187 | + | // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted. | |
| 188 | + | require.Equal(t, 'a', frame.At(2, 1).Rune) | |
| 189 | + | require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style") | |
| 190 | + | require.Equal(t, ' ', frame.At(7, 1).Rune) | |
| 191 | + | require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |
| 192 | + | require.Equal(t, '(', frame.At(8, 1).Rune) | |
| 193 | + | require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |
| 194 | + | ||
| 195 | + | // Row 2 is the non-selected tab ("beta"): name uses Name, then the | |
| 196 | + | // attention marker (right after the name, before detail) uses the row's | |
| 197 | + | // base style (not muted), then detail uses Detail. | |
| 198 | + | require.Equal(t, 'b', frame.At(2, 2).Rune) | |
| 199 | + | require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style") | |
| 200 | + | require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space") | |
| 201 | + | require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 202 | + | require.Equal(t, rune(ui.AttentionGlyph), frame.At(7, 2).Rune) | |
| 203 | + | require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 204 | + | require.Equal(t, ' ', frame.At(8, 2).Rune) | |
| 205 | + | require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style") | |
| 206 | + | require.Equal(t, '(', frame.At(9, 2).Rune) | |
| 207 | + | require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style") | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |
| 211 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 212 | + | {Name: "short-name", Detail: " (a very long detail text)"}, | |
| 213 | + | }, Active: 0}}, "s1", 0) | |
| 214 | + | ||
| 215 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 216 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 217 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 218 | + | ||
| 219 | + | want := " short-name (a…" | |
| 220 | + | for i, r := range want { | |
| 221 | + | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | |
| 222 | + | } | |
| 223 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched") | |
| 224 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width") | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |
| 228 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 229 | + | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | |
| 230 | + | }, Active: 0}}, "s1", 0) | |
| 231 | + | ||
| 232 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 233 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 234 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 235 | + | ||
| 236 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | |
| 237 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all") | |
| 238 | + | } | |
| 239 | + | ||
| 167 | 240 | func cell(r rune) renderer.Cell { | |
| 168 | 241 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 169 | 242 | } | |
| @@ -4,14 +4,15 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 7 | "github.com/bnema/vev/pkg/renderer" | 8 | "github.com/bnema/vev/pkg/renderer" | ||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | 12 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | ||
| 12 | m := New([]SessionView{ | 13 | m := New([]SessionView{ | ||
| 13 | - | {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0}, | 14 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0}, |
| 14 | - | {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0}, | 15 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0}, |
| 15 | }, "s1", 1) | 16 | }, "s1", 1) | ||
| 16 | 17 | ||||
| 17 | got, ok := m.Selected() | 18 | got, ok := m.Selected() | ||
| @@ -25,12 +26,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |||||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | 28 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | ||
| 28 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0) | 29 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0) |
| 29 | got, ok := m.Selected() | 30 | got, ok := m.Selected() | ||
| 30 | require.True(t, ok) | 31 | require.True(t, ok) | ||
| 31 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | 32 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | ||
| 32 | 33 | ||||
| 33 | - | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0) | 34 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0) |
| 34 | got, ok = m.Selected() | 35 | got, ok = m.Selected() | ||
| 35 | require.True(t, ok) | 36 | require.True(t, ok) | ||
| 36 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | 37 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | ||
| @@ -38,8 +39,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |||||
| 38 | 39 | ||||
| 39 | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | 40 | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | ||
| 40 | m := New([]SessionView{ | 41 | m := New([]SessionView{ | ||
| 41 | - | {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0}, | 42 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0}, |
| 42 | - | {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0}, | 43 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0}, |
| 43 | }, "s1", 0) | 44 | }, "s1", 0) | ||
| 44 | 45 | ||||
| 45 | m.Up() | 46 | m.Up() | ||
| @@ -92,8 +93,8 @@ func TestChooseLayoutBoundaries(t *testing.T) { | |||||
| 92 | 93 | ||||
| 93 | func TestSelectedMapping(t *testing.T) { | 94 | func TestSelectedMapping(t *testing.T) { | ||
| 94 | m := New([]SessionView{ | 95 | m := New([]SessionView{ | ||
| 95 | - | {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0}, | 96 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, |
| 96 | - | {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0}, | 97 | + | {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0}, |
| 97 | }, "beta", 1) | 98 | }, "beta", 1) | ||
| 98 | 99 | ||||
| 99 | got, ok := m.Selected() | 100 | got, ok := m.Selected() | ||
| @@ -102,7 +103,7 @@ func TestSelectedMapping(t *testing.T) { | |||||
| 102 | } | 103 | } | ||
| 103 | 104 | ||||
| 104 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | 105 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | ||
| 105 | - | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0) | 106 | + | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0) |
| 106 | got, ok := m.Selected() | 107 | got, ok := m.Selected() | ||
| 107 | require.True(t, ok) | 108 | require.True(t, ok) | ||
| 108 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | 109 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | ||
| @@ -112,7 +113,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||||
| 112 | } | 113 | } | ||
| 113 | 114 | ||||
| 114 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | 115 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | ||
| 115 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | 116 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) |
| 116 | preview := Preview{ | 117 | preview := Preview{ | ||
| 117 | Width: 24, | 118 | Width: 24, | ||
| 118 | Height: 1, | 119 | Height: 1, | ||
| @@ -132,7 +133,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||||
| 132 | } | 133 | } | ||
| 133 | 134 | ||||
| 134 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | 135 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | ||
| 135 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0) | 136 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0) |
| 136 | for range 5 { | 137 | for range 5 { | ||
| 137 | m.Down() | 138 | m.Down() | ||
| 138 | } | 139 | } | ||
| @@ -144,7 +145,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||||
| 144 | } | 145 | } | ||
| 145 | 146 | ||||
| 146 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | 147 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | ||
| 147 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0) | 148 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) |
| 148 | 149 | ||||
| 149 | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | 150 | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | ||
| 150 | 151 | ||||
| @@ -155,7 +156,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||||
| 155 | } | 156 | } | ||
| 156 | 157 | ||||
| 157 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | 158 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | ||
| 158 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | 159 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) |
| 159 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | 160 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | ||
| 160 | 161 | ||||
| 161 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | 162 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | ||
| @@ -164,6 +165,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |||||
| 164 | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | 165 | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | ||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 168 | + | func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||
| 169 | + | nameStyle := renderer.Style{Bold: true} | |||
| 170 | + | detailStyle := renderer.Style{Italic: true} | |||
| 171 | + | baseStyle := renderer.DefaultStyle() | |||
| 172 | + | selectionStyle := renderer.Style{Inverse: true} | |||
| 173 | + | selectionNameStyle := renderer.Style{Inverse: true, Bold: true} | |||
| 174 | + | selectionMutedStyle := renderer.Style{Inverse: true, Italic: true} | |||
| 175 | + | styles := RenderStyles{ | |||
| 176 | + | Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle, | |||
| 177 | + | Name: nameStyle, Detail: detailStyle, Base: baseStyle, | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |||
| 181 | + | {Name: "alpha", Detail: " (running)"}, | |||
| 182 | + | {Name: "beta", Detail: " (idle)", Attention: true}, | |||
| 183 | + | }, Active: 0}}, "s1", 0) | |||
| 184 | + | ||||
| 185 | + | frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles) | |||
| 186 | + | ||||
| 187 | + | // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted. | |||
| 188 | + | require.Equal(t, 'a', frame.At(2, 1).Rune) | |||
| 189 | + | require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style") | |||
| 190 | + | require.Equal(t, ' ', frame.At(7, 1).Rune) | |||
| 191 | + | require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |||
| 192 | + | require.Equal(t, '(', frame.At(8, 1).Rune) | |||
| 193 | + | require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |||
| 194 | + | ||||
| 195 | + | // Row 2 is the non-selected tab ("beta"): name uses Name, then the | |||
| 196 | + | // attention marker (right after the name, before detail) uses the row's | |||
| 197 | + | // base style (not muted), then detail uses Detail. | |||
| 198 | + | require.Equal(t, 'b', frame.At(2, 2).Rune) | |||
| 199 | + | require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style") | |||
| 200 | + | require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space") | |||
| 201 | + | require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |||
| 202 | + | require.Equal(t, rune(ui.AttentionGlyph), frame.At(7, 2).Rune) | |||
| 203 | + | require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |||
| 204 | + | require.Equal(t, ' ', frame.At(8, 2).Rune) | |||
| 205 | + | require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style") | |||
| 206 | + | require.Equal(t, '(', frame.At(9, 2).Rune) | |||
| 207 | + | require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style") | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||
| 211 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |||
| 212 | + | {Name: "short-name", Detail: " (a very long detail text)"}, | |||
| 213 | + | }, Active: 0}}, "s1", 0) | |||
| 214 | + | ||||
| 215 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |||
| 216 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |||
| 217 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |||
| 218 | + | ||||
| 219 | + | want := " short-name (a…" | |||
| 220 | + | for i, r := range want { | |||
| 221 | + | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | |||
| 222 | + | } | |||
| 223 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched") | |||
| 224 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width") | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||
| 228 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |||
| 229 | + | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | |||
| 230 | + | }, Active: 0}}, "s1", 0) | |||
| 231 | + | ||||
| 232 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |||
| 233 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |||
| 234 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |||
| 235 | + | ||||
| 236 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | |||
| 237 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all") | |||
| 238 | + | } | |||
| 239 | + | ||||
| 167 | func cell(r rune) renderer.Cell { | 240 | func cell(r rune) renderer.Cell { | ||
| 168 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | 241 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | ||
| 169 | } | 242 | } | ||
M internal/usecase/theme/theme.go +36 -0
| @@ -337,6 +337,42 @@ func MutedTextStyle(t Theme) renderer.Style { | |||
| 337 | 337 | return style | |
| 338 | 338 | } | |
| 339 | 339 | ||
| 340 | + | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | |
| 341 | + | // foreground toward its own background, subtler than MutedTextStyle's 0.45 | |
| 342 | + | // (which blends the theme's global fg/bg instead of a style's own colors). | |
| 343 | + | const mutedVariantBlend = 0.4 | |
| 344 | + | ||
| 345 | + | // EmphasisStyle returns base with bold applied when the client theme supports | |
| 346 | + | // styled output; otherwise base is returned unchanged. | |
| 347 | + | func EmphasisStyle(base renderer.Style, t Theme) renderer.Style { | |
| 348 | + | if !usable(t) { | |
| 349 | + | return base | |
| 350 | + | } | |
| 351 | + | out := base | |
| 352 | + | out.Bold = true | |
| 353 | + | return out | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // MutedVariantStyle fades base's foreground toward its background when both | |
| 357 | + | // are RGB and the client theme supports styled output; otherwise base is | |
| 358 | + | // returned unchanged. When base has an RGB foreground but no RGB background, | |
| 359 | + | // it fades toward t.Background instead. | |
| 360 | + | func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |
| 361 | + | if !usable(t) || !base.HasForegroundRGB { | |
| 362 | + | return base | |
| 363 | + | } | |
| 364 | + | bg := base.BackgroundRGB | |
| 365 | + | if !base.HasBackgroundRGB { | |
| 366 | + | if !t.HasBG { | |
| 367 | + | return base | |
| 368 | + | } | |
| 369 | + | bg = t.Background | |
| 370 | + | } | |
| 371 | + | out := base | |
| 372 | + | out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend) | |
| 373 | + | return out | |
| 374 | + | } | |
| 375 | + | ||
| 340 | 376 | func DimStyle(style renderer.Style, t Theme) renderer.Style { | |
| 341 | 377 | if !usable(t) { | |
| 342 | 378 | return style | |
| @@ -337,6 +337,42 @@ func MutedTextStyle(t Theme) renderer.Style { | |||||
| 337 | return style | 337 | return style | ||
| 338 | } | 338 | } | ||
| 339 | 339 | ||||
| 340 | + | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | |||
| 341 | + | // foreground toward its own background, subtler than MutedTextStyle's 0.45 | |||
| 342 | + | // (which blends the theme's global fg/bg instead of a style's own colors). | |||
| 343 | + | const mutedVariantBlend = 0.4 | |||
| 344 | + | ||||
| 345 | + | // EmphasisStyle returns base with bold applied when the client theme supports | |||
| 346 | + | // styled output; otherwise base is returned unchanged. | |||
| 347 | + | func EmphasisStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 348 | + | if !usable(t) { | |||
| 349 | + | return base | |||
| 350 | + | } | |||
| 351 | + | out := base | |||
| 352 | + | out.Bold = true | |||
| 353 | + | return out | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // MutedVariantStyle fades base's foreground toward its background when both | |||
| 357 | + | // are RGB and the client theme supports styled output; otherwise base is | |||
| 358 | + | // returned unchanged. When base has an RGB foreground but no RGB background, | |||
| 359 | + | // it fades toward t.Background instead. | |||
| 360 | + | func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 361 | + | if !usable(t) || !base.HasForegroundRGB { | |||
| 362 | + | return base | |||
| 363 | + | } | |||
| 364 | + | bg := base.BackgroundRGB | |||
| 365 | + | if !base.HasBackgroundRGB { | |||
| 366 | + | if !t.HasBG { | |||
| 367 | + | return base | |||
| 368 | + | } | |||
| 369 | + | bg = t.Background | |||
| 370 | + | } | |||
| 371 | + | out := base | |||
| 372 | + | out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend) | |||
| 373 | + | return out | |||
| 374 | + | } | |||
| 375 | + | ||||
| 340 | func DimStyle(style renderer.Style, t Theme) renderer.Style { | 376 | func DimStyle(style renderer.Style, t Theme) renderer.Style { | ||
| 341 | if !usable(t) { | 377 | if !usable(t) { | ||
| 342 | return style | 378 | return style | ||
M internal/usecase/theme/theme_test.go +98 -0
| @@ -388,3 +388,101 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |||
| 388 | 388 | t.Fatalf("muted themed=%+v", muted) | |
| 389 | 389 | } | |
| 390 | 390 | } | |
| 391 | + | ||
| 392 | + | func TestEmphasisStyle(t *testing.T) { | |
| 393 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 394 | + | base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}} | |
| 395 | + | ||
| 396 | + | tests := []struct { | |
| 397 | + | name string | |
| 398 | + | theme Theme | |
| 399 | + | base renderer.Style | |
| 400 | + | want renderer.Style | |
| 401 | + | }{ | |
| 402 | + | { | |
| 403 | + | name: "usable theme applies bold", | |
| 404 | + | theme: usableTheme, | |
| 405 | + | base: base, | |
| 406 | + | want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true}, | |
| 407 | + | }, | |
| 408 | + | { | |
| 409 | + | name: "unknown theme returns base unchanged", | |
| 410 | + | theme: Theme{}, | |
| 411 | + | base: base, | |
| 412 | + | want: base, | |
| 413 | + | }, | |
| 414 | + | { | |
| 415 | + | name: "theme missing truecolor returns base unchanged", | |
| 416 | + | theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true}, | |
| 417 | + | base: base, | |
| 418 | + | want: base, | |
| 419 | + | }, | |
| 420 | + | } | |
| 421 | + | for _, tt := range tests { | |
| 422 | + | t.Run(tt.name, func(t *testing.T) { | |
| 423 | + | if got := EmphasisStyle(tt.base, tt.theme); !got.Equal(tt.want) { | |
| 424 | + | t.Fatalf("EmphasisStyle()=%+v want %+v", got, tt.want) | |
| 425 | + | } | |
| 426 | + | }) | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func TestMutedVariantStyle(t *testing.T) { | |
| 431 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 432 | + | rgbBase := renderer.Style{ | |
| 433 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 434 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0}, | |
| 435 | + | } | |
| 436 | + | fgOnlyBase := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}} | |
| 437 | + | noRGBBase := renderer.DefaultStyle() | |
| 438 | + | ||
| 439 | + | tests := []struct { | |
| 440 | + | name string | |
| 441 | + | base renderer.Style | |
| 442 | + | theme Theme | |
| 443 | + | want renderer.Style | |
| 444 | + | }{ | |
| 445 | + | { | |
| 446 | + | name: "usable theme blends fg toward own bg", | |
| 447 | + | base: rgbBase, | |
| 448 | + | theme: usableTheme, | |
| 449 | + | want: renderer.Style{ | |
| 450 | + | HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, renderer.RGB{R: 0, G: 0, B: 0}, mutedVariantBlend), | |
| 451 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0}, | |
| 452 | + | }, | |
| 453 | + | }, | |
| 454 | + | { | |
| 455 | + | name: "non-usable theme returns base unchanged", | |
| 456 | + | base: rgbBase, | |
| 457 | + | theme: Theme{}, | |
| 458 | + | want: rgbBase, | |
| 459 | + | }, | |
| 460 | + | { | |
| 461 | + | name: "base without RGB background falls back to theme background", | |
| 462 | + | base: fgOnlyBase, | |
| 463 | + | theme: usableTheme, | |
| 464 | + | want: renderer.Style{ | |
| 465 | + | HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, usableTheme.Background, mutedVariantBlend), | |
| 466 | + | }, | |
| 467 | + | }, | |
| 468 | + | { | |
| 469 | + | name: "base without RGB background and theme without background returns base unchanged", | |
| 470 | + | base: fgOnlyBase, | |
| 471 | + | theme: Theme{Known: true, TrueColor: true, HasFG: true}, | |
| 472 | + | want: fgOnlyBase, | |
| 473 | + | }, | |
| 474 | + | { | |
| 475 | + | name: "base without RGB foreground returns base unchanged", | |
| 476 | + | base: noRGBBase, | |
| 477 | + | theme: usableTheme, | |
| 478 | + | want: noRGBBase, | |
| 479 | + | }, | |
| 480 | + | } | |
| 481 | + | for _, tt := range tests { | |
| 482 | + | t.Run(tt.name, func(t *testing.T) { | |
| 483 | + | if got := MutedVariantStyle(tt.base, tt.theme); !got.Equal(tt.want) { | |
| 484 | + | t.Fatalf("MutedVariantStyle()=%+v want %+v", got, tt.want) | |
| 485 | + | } | |
| 486 | + | }) | |
| 487 | + | } | |
| 488 | + | } | |
| @@ -388,3 +388,101 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |||||
| 388 | t.Fatalf("muted themed=%+v", muted) | 388 | t.Fatalf("muted themed=%+v", muted) | ||
| 389 | } | 389 | } | ||
| 390 | } | 390 | } | ||
| 391 | + | ||||
| 392 | + | func TestEmphasisStyle(t *testing.T) { | |||
| 393 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |||
| 394 | + | base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}} | |||
| 395 | + | ||||
| 396 | + | tests := []struct { | |||
| 397 | + | name string | |||
| 398 | + | theme Theme | |||
| 399 | + | base renderer.Style | |||
| 400 | + | want renderer.Style | |||
| 401 | + | }{ | |||
| 402 | + | { | |||
| 403 | + | name: "usable theme applies bold", | |||
| 404 | + | theme: usableTheme, | |||
| 405 | + | base: base, | |||
| 406 | + | want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true}, | |||
| 407 | + | }, | |||
| 408 | + | { | |||
| 409 | + | name: "unknown theme returns base unchanged", | |||
| 410 | + | theme: Theme{}, | |||
| 411 | + | base: base, | |||
| 412 | + | want: base, | |||
| 413 | + | }, | |||
| 414 | + | { | |||
| 415 | + | name: "theme missing truecolor returns base unchanged", | |||
| 416 | + | theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true}, | |||
| 417 | + | base: base, | |||
| 418 | + | want: base, | |||
| 419 | + | }, | |||
| 420 | + | } | |||
| 421 | + | for _, tt := range tests { | |||
| 422 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 423 | + | if got := EmphasisStyle(tt.base, tt.theme); !got.Equal(tt.want) { | |||
| 424 | + | t.Fatalf("EmphasisStyle()=%+v want %+v", got, tt.want) | |||
| 425 | + | } | |||
| 426 | + | }) | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func TestMutedVariantStyle(t *testing.T) { | |||
| 431 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |||
| 432 | + | rgbBase := renderer.Style{ | |||
| 433 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}, | |||
| 434 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0}, | |||
| 435 | + | } | |||
| 436 | + | fgOnlyBase := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}} | |||
| 437 | + | noRGBBase := renderer.DefaultStyle() | |||
| 438 | + | ||||
| 439 | + | tests := []struct { | |||
| 440 | + | name string | |||
| 441 | + | base renderer.Style | |||
| 442 | + | theme Theme | |||
| 443 | + | want renderer.Style | |||
| 444 | + | }{ | |||
| 445 | + | { | |||
| 446 | + | name: "usable theme blends fg toward own bg", | |||
| 447 | + | base: rgbBase, | |||
| 448 | + | theme: usableTheme, | |||
| 449 | + | want: renderer.Style{ | |||
| 450 | + | HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, renderer.RGB{R: 0, G: 0, B: 0}, mutedVariantBlend), | |||
| 451 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0}, | |||
| 452 | + | }, | |||
| 453 | + | }, | |||
| 454 | + | { | |||
| 455 | + | name: "non-usable theme returns base unchanged", | |||
| 456 | + | base: rgbBase, | |||
| 457 | + | theme: Theme{}, | |||
| 458 | + | want: rgbBase, | |||
| 459 | + | }, | |||
| 460 | + | { | |||
| 461 | + | name: "base without RGB background falls back to theme background", | |||
| 462 | + | base: fgOnlyBase, | |||
| 463 | + | theme: usableTheme, | |||
| 464 | + | want: renderer.Style{ | |||
| 465 | + | HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, usableTheme.Background, mutedVariantBlend), | |||
| 466 | + | }, | |||
| 467 | + | }, | |||
| 468 | + | { | |||
| 469 | + | name: "base without RGB background and theme without background returns base unchanged", | |||
| 470 | + | base: fgOnlyBase, | |||
| 471 | + | theme: Theme{Known: true, TrueColor: true, HasFG: true}, | |||
| 472 | + | want: fgOnlyBase, | |||
| 473 | + | }, | |||
| 474 | + | { | |||
| 475 | + | name: "base without RGB foreground returns base unchanged", | |||
| 476 | + | base: noRGBBase, | |||
| 477 | + | theme: usableTheme, | |||
| 478 | + | want: noRGBBase, | |||
| 479 | + | }, | |||
| 480 | + | } | |||
| 481 | + | for _, tt := range tests { | |||
| 482 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 483 | + | if got := MutedVariantStyle(tt.base, tt.theme); !got.Equal(tt.want) { | |||
| 484 | + | t.Fatalf("MutedVariantStyle()=%+v want %+v", got, tt.want) | |||
| 485 | + | } | |||
| 486 | + | }) | |||
| 487 | + | } | |||
| 488 | + | } | |||
A internal/usecase/ui/glyphs.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | // AttentionGlyph is the bell icon drawn beside tab/session labels with a | |
| 4 | + | // pending attention event. | |
| 5 | + | const AttentionGlyph = '' | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | // AttentionGlyph is the bell icon drawn beside tab/session labels with a | |||
| 4 | + | // pending attention event. | |||
| 5 | + | const AttentionGlyph = '' | |||