feat(daemon): bold tab names and mute pane titles on truecolor clients
Split tab labels ("name (process: title)") into a bold name segment and a
muted parenthesized pane-title segment in the top bar and session picker,
but only when the attached client's theme is truecolor-capable; on other
clients rendering is byte-for-byte unchanged. Adds EmphasisStyle and
MutedVariantStyle to internal/usecase/theme as the reusable no-op-unless-
usable building blocks, threads the split through fitTabLabels' new
fittedTabLabel return type in the top bar, and widens the picker's
SessionView.Tabs to []TabEntry{Name, Detail, Attention} plus a
RenderStyles struct (mirroring palette.RenderStyles) so the picker package
still doesn't need to import internal/usecase/theme.- commit
- 163735ec7d43d840907075105665994c0dae0160
- author
- brice · · 2026-07-11 11:27 UTC
- parent
- e94bd47
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +493 -79
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 +18 -7
| @@ -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 | } | |
| @@ -490,8 +493,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 490 | 493 | ||
| 491 | 494 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 492 | 495 | styleSet := resolveThemeStyles(styles) | |
| 493 | - | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame { | |
| 494 | - | return model.Render(size, preview, styles...) | |
| 496 | + | renderStyles := picker.RenderStyles{ | |
| 497 | + | Selection: styleSet.selection, | |
| 498 | + | SelectionName: styleSet.pickerSelectionName, | |
| 499 | + | SelectionMuted: styleSet.pickerSelectionMuted, | |
| 500 | + | Name: styleSet.pickerName, | |
| 501 | + | Detail: styleSet.paletteDesc, | |
| 502 | + | Base: renderer.DefaultStyle(), | |
| 503 | + | } | |
| 504 | + | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 505 | + | return model.Render(size, preview, renderStyles) | |
| 495 | 506 | }) | |
| 496 | 507 | } | |
| 497 | 508 | ||
| @@ -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 | } | ||
| @@ -490,8 +493,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||||
| 490 | 493 | ||||
| 491 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | 494 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | ||
| 492 | styleSet := resolveThemeStyles(styles) | 495 | styleSet := resolveThemeStyles(styles) | ||
| 493 | - | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame { | 496 | + | renderStyles := picker.RenderStyles{ |
| 494 | - | return model.Render(size, preview, styles...) | 497 | + | Selection: styleSet.selection, |
| 498 | + | SelectionName: styleSet.pickerSelectionName, | |||
| 499 | + | SelectionMuted: styleSet.pickerSelectionMuted, | |||
| 500 | + | Name: styleSet.pickerName, | |||
| 501 | + | Detail: styleSet.paletteDesc, | |||
| 502 | + | Base: renderer.DefaultStyle(), | |||
| 503 | + | } | |||
| 504 | + | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |||
| 505 | + | return model.Render(size, preview, renderStyles) | |||
| 495 | }) | 506 | }) | ||
| 496 | } | 507 | } | ||
| 497 | 508 | ||||
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 +30 -4
| @@ -576,16 +576,42 @@ type themeStyles struct { | |||
| 576 | 576 | selection renderer.Style | |
| 577 | 577 | copyStatus renderer.Style | |
| 578 | 578 | paletteDesc renderer.Style | |
| 579 | + | ||
| 580 | + | // Top bar tab label segments: name (bold) and parenthesized pane-title | |
| 581 | + | // (muted), one pair per base style. No-ops to statusBar/accent on | |
| 582 | + | // non-truecolor themes. | |
| 583 | + | tabName renderer.Style | |
| 584 | + | tabNameActive renderer.Style | |
| 585 | + | tabTitle renderer.Style | |
| 586 | + | tabTitleActive renderer.Style | |
| 587 | + | ||
| 588 | + | // Session picker row segments. Detail reuses paletteDesc (both are | |
| 589 | + | // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only. | |
| 590 | + | pickerName renderer.Style | |
| 591 | + | pickerSelectionName renderer.Style | |
| 592 | + | pickerSelectionMuted renderer.Style | |
| 579 | 593 | } | |
| 580 | 594 | ||
| 581 | 595 | func newThemeStyles(t themeui.Theme) themeStyles { | |
| 596 | + | statusBar := themeui.StatusBarStyle(t) | |
| 597 | + | accent := themeui.AccentStyle(t) | |
| 598 | + | selection := themeui.SelectionStyle(t) | |
| 582 | 599 | return themeStyles{ | |
| 583 | - | statusBar: themeui.StatusBarStyle(t), | |
| 584 | - | accent: themeui.AccentStyle(t), | |
| 600 | + | statusBar: statusBar, | |
| 601 | + | accent: accent, | |
| 585 | 602 | border: themeui.BorderStyle(t), | |
| 586 | - | selection: themeui.SelectionStyle(t), | |
| 587 | - | copyStatus: themeui.SelectionStyle(t), | |
| 603 | + | selection: selection, | |
| 604 | + | copyStatus: selection, | |
| 588 | 605 | paletteDesc: themeui.MutedTextStyle(t), | |
| 606 | + | ||
| 607 | + | tabName: themeui.EmphasisStyle(statusBar, t), | |
| 608 | + | tabNameActive: themeui.EmphasisStyle(accent, t), | |
| 609 | + | tabTitle: themeui.MutedVariantStyle(statusBar, t), | |
| 610 | + | tabTitleActive: themeui.MutedVariantStyle(accent, t), | |
| 611 | + | ||
| 612 | + | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |
| 613 | + | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |
| 614 | + | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |
| 589 | 615 | } | |
| 590 | 616 | } | |
| 591 | 617 | ||
| @@ -576,16 +576,42 @@ type themeStyles struct { | |||||
| 576 | selection renderer.Style | 576 | selection renderer.Style | ||
| 577 | copyStatus renderer.Style | 577 | copyStatus renderer.Style | ||
| 578 | paletteDesc renderer.Style | 578 | paletteDesc renderer.Style | ||
| 579 | + | ||||
| 580 | + | // Top bar tab label segments: name (bold) and parenthesized pane-title | |||
| 581 | + | // (muted), one pair per base style. No-ops to statusBar/accent on | |||
| 582 | + | // non-truecolor themes. | |||
| 583 | + | tabName renderer.Style | |||
| 584 | + | tabNameActive renderer.Style | |||
| 585 | + | tabTitle renderer.Style | |||
| 586 | + | tabTitleActive renderer.Style | |||
| 587 | + | ||||
| 588 | + | // Session picker row segments. Detail reuses paletteDesc (both are | |||
| 589 | + | // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only. | |||
| 590 | + | pickerName renderer.Style | |||
| 591 | + | pickerSelectionName renderer.Style | |||
| 592 | + | pickerSelectionMuted renderer.Style | |||
| 579 | } | 593 | } | ||
| 580 | 594 | ||||
| 581 | func newThemeStyles(t themeui.Theme) themeStyles { | 595 | func newThemeStyles(t themeui.Theme) themeStyles { | ||
| 596 | + | statusBar := themeui.StatusBarStyle(t) | |||
| 597 | + | accent := themeui.AccentStyle(t) | |||
| 598 | + | selection := themeui.SelectionStyle(t) | |||
| 582 | return themeStyles{ | 599 | return themeStyles{ | ||
| 583 | - | statusBar: themeui.StatusBarStyle(t), | 600 | + | statusBar: statusBar, |
| 584 | - | accent: themeui.AccentStyle(t), | 601 | + | accent: accent, |
| 585 | border: themeui.BorderStyle(t), | 602 | border: themeui.BorderStyle(t), | ||
| 586 | - | selection: themeui.SelectionStyle(t), | 603 | + | selection: selection, |
| 587 | - | copyStatus: themeui.SelectionStyle(t), | 604 | + | copyStatus: selection, |
| 588 | paletteDesc: themeui.MutedTextStyle(t), | 605 | paletteDesc: themeui.MutedTextStyle(t), | ||
| 606 | + | ||||
| 607 | + | tabName: themeui.EmphasisStyle(statusBar, t), | |||
| 608 | + | tabNameActive: themeui.EmphasisStyle(accent, t), | |||
| 609 | + | tabTitle: themeui.MutedVariantStyle(statusBar, t), | |||
| 610 | + | tabTitleActive: themeui.MutedVariantStyle(accent, t), | |||
| 611 | + | ||||
| 612 | + | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |||
| 613 | + | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |||
| 614 | + | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |||
| 589 | } | 615 | } | ||
| 590 | } | 616 | } | ||
| 591 | 617 | ||||
M internal/usecase/daemon/status.go +30 -12
| @@ -66,16 +66,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 66 | 66 | x := 0 | |
| 67 | 67 | labels := fitTabLabels(status.tabs, len(row), topRight) | |
| 68 | 68 | for i, w := range status.tabs { | |
| 69 | - | style := styles.statusBar | |
| 69 | + | baseStyle := styles.statusBar | |
| 70 | + | nameStyle := styles.tabName | |
| 71 | + | titleStyle := styles.tabTitle | |
| 70 | 72 | if w.active { | |
| 71 | - | style = styles.accent | |
| 73 | + | baseStyle = styles.accent | |
| 74 | + | nameStyle = styles.tabNameActive | |
| 75 | + | titleStyle = styles.tabTitleActive | |
| 72 | 76 | } | |
| 73 | - | writeStatusText(row, &x, " "+labels[i], style) | |
| 77 | + | label := labels[i] | |
| 78 | + | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| 79 | + | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 74 | 80 | if w.attention { | |
| 75 | - | writeStatusText(row, &x, " ", style) | |
| 81 | + | writeStatusText(row, &x, " ", baseStyle) | |
| 76 | 82 | writeBell(row, &x, frame) | |
| 77 | 83 | } | |
| 78 | - | writeStatusText(row, &x, " ", style) | |
| 84 | + | writeStatusText(row, &x, " ", baseStyle) | |
| 79 | 85 | } | |
| 80 | 86 | drawRightPlainText(row, topRight, x, styles.statusBar) | |
| 81 | 87 | } | |
| @@ -383,9 +389,18 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s | |||
| 383 | 389 | return entries[start:end] | |
| 384 | 390 | } | |
| 385 | 391 | ||
| 392 | + | // fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name | |
| 393 | + | // segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is | |
| 394 | + | // always a byte offset landing on a rune boundary, since it is derived from | |
| 395 | + | // the byte length of the (possibly truncated) name prefix. | |
| 396 | + | type fittedTabLabel struct { | |
| 397 | + | text string | |
| 398 | + | nameLen int | |
| 399 | + | } | |
| 400 | + | ||
| 386 | 401 | // fitTabLabels returns, per tab, the text drawn between its surrounding | |
| 387 | 402 | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | |
| 388 | - | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |
| 403 | + | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel { | |
| 389 | 404 | reserve := 1 | |
| 390 | 405 | if rightText != "" { | |
| 391 | 406 | reserve = statusTextWidth(rightText) + 2 | |
| @@ -406,9 +421,11 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||
| 406 | 421 | widths[i] = statusTextWidth(full[i]) | |
| 407 | 422 | total += o + widths[i] | |
| 408 | 423 | } | |
| 409 | - | labels := make([]string, len(tabs)) | |
| 424 | + | labels := make([]fittedTabLabel, len(tabs)) | |
| 410 | 425 | if total <= budget { | |
| 411 | - | copy(labels, full) | |
| 426 | + | for i, t := range tabs { | |
| 427 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)} | |
| 428 | + | } | |
| 412 | 429 | return labels | |
| 413 | 430 | } | |
| 414 | 431 | ||
| @@ -430,13 +447,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||
| 430 | 447 | textBudget := share - overhead[i] | |
| 431 | 448 | switch { | |
| 432 | 449 | case textBudget <= 0: | |
| 433 | - | labels[i] = "" | |
| 450 | + | labels[i] = fittedTabLabel{} | |
| 434 | 451 | case widths[i] <= textBudget: | |
| 435 | - | labels[i] = full[i] | |
| 452 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)} | |
| 436 | 453 | case textBudget >= statusTextWidth(tabs[i].name)+4: | |
| 437 | - | labels[i] = ui.TruncateText(full[i], textBudget) | |
| 454 | + | labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)} | |
| 438 | 455 | default: | |
| 439 | - | labels[i] = ui.TruncateText(tabs[i].name, textBudget) | |
| 456 | + | text := ui.TruncateText(tabs[i].name, textBudget) | |
| 457 | + | labels[i] = fittedTabLabel{text: text, nameLen: len(text)} | |
| 440 | 458 | } | |
| 441 | 459 | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | |
| 442 | 460 | remaining -= consumed | |
| @@ -66,16 +66,22 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||||
| 66 | x := 0 | 66 | x := 0 | ||
| 67 | labels := fitTabLabels(status.tabs, len(row), topRight) | 67 | labels := fitTabLabels(status.tabs, len(row), topRight) | ||
| 68 | for i, w := range status.tabs { | 68 | for i, w := range status.tabs { | ||
| 69 | - | style := styles.statusBar | 69 | + | baseStyle := styles.statusBar |
| 70 | + | nameStyle := styles.tabName | |||
| 71 | + | titleStyle := styles.tabTitle | |||
| 70 | if w.active { | 72 | if w.active { | ||
| 71 | - | style = styles.accent | 73 | + | baseStyle = styles.accent |
| 74 | + | nameStyle = styles.tabNameActive | |||
| 75 | + | titleStyle = styles.tabTitleActive | |||
| 72 | } | 76 | } | ||
| 73 | - | writeStatusText(row, &x, " "+labels[i], style) | 77 | + | label := labels[i] |
| 78 | + | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |||
| 79 | + | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |||
| 74 | if w.attention { | 80 | if w.attention { | ||
| 75 | - | writeStatusText(row, &x, " ", style) | 81 | + | writeStatusText(row, &x, " ", baseStyle) |
| 76 | writeBell(row, &x, frame) | 82 | writeBell(row, &x, frame) | ||
| 77 | } | 83 | } | ||
| 78 | - | writeStatusText(row, &x, " ", style) | 84 | + | writeStatusText(row, &x, " ", baseStyle) |
| 79 | } | 85 | } | ||
| 80 | drawRightPlainText(row, topRight, x, styles.statusBar) | 86 | drawRightPlainText(row, topRight, x, styles.statusBar) | ||
| 81 | } | 87 | } | ||
| @@ -383,9 +389,18 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s | |||||
| 383 | return entries[start:end] | 389 | return entries[start:end] | ||
| 384 | } | 390 | } | ||
| 385 | 391 | ||||
| 392 | + | // fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name | |||
| 393 | + | // segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is | |||
| 394 | + | // always a byte offset landing on a rune boundary, since it is derived from | |||
| 395 | + | // the byte length of the (possibly truncated) name prefix. | |||
| 396 | + | type fittedTabLabel struct { | |||
| 397 | + | text string | |||
| 398 | + | nameLen int | |||
| 399 | + | } | |||
| 400 | + | ||||
| 386 | // fitTabLabels returns, per tab, the text drawn between its surrounding | 401 | // fitTabLabels returns, per tab, the text drawn between its surrounding | ||
| 387 | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | 402 | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | ||
| 388 | - | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | 403 | + | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel { |
| 389 | reserve := 1 | 404 | reserve := 1 | ||
| 390 | if rightText != "" { | 405 | if rightText != "" { | ||
| 391 | reserve = statusTextWidth(rightText) + 2 | 406 | reserve = statusTextWidth(rightText) + 2 | ||
| @@ -406,9 +421,11 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||||
| 406 | widths[i] = statusTextWidth(full[i]) | 421 | widths[i] = statusTextWidth(full[i]) | ||
| 407 | total += o + widths[i] | 422 | total += o + widths[i] | ||
| 408 | } | 423 | } | ||
| 409 | - | labels := make([]string, len(tabs)) | 424 | + | labels := make([]fittedTabLabel, len(tabs)) |
| 410 | if total <= budget { | 425 | if total <= budget { | ||
| 411 | - | copy(labels, full) | 426 | + | for i, t := range tabs { |
| 427 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)} | |||
| 428 | + | } | |||
| 412 | return labels | 429 | return labels | ||
| 413 | } | 430 | } | ||
| 414 | 431 | ||||
| @@ -430,13 +447,14 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||||
| 430 | textBudget := share - overhead[i] | 447 | textBudget := share - overhead[i] | ||
| 431 | switch { | 448 | switch { | ||
| 432 | case textBudget <= 0: | 449 | case textBudget <= 0: | ||
| 433 | - | labels[i] = "" | 450 | + | labels[i] = fittedTabLabel{} |
| 434 | case widths[i] <= textBudget: | 451 | case widths[i] <= textBudget: | ||
| 435 | - | labels[i] = full[i] | 452 | + | labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)} |
| 436 | case textBudget >= statusTextWidth(tabs[i].name)+4: | 453 | case textBudget >= statusTextWidth(tabs[i].name)+4: | ||
| 437 | - | labels[i] = ui.TruncateText(full[i], textBudget) | 454 | + | labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)} |
| 438 | default: | 455 | default: | ||
| 439 | - | labels[i] = ui.TruncateText(tabs[i].name, textBudget) | 456 | + | text := ui.TruncateText(tabs[i].name, textBudget) |
| 457 | + | labels[i] = fittedTabLabel{text: text, nameLen: len(text)} | |||
| 440 | } | 458 | } | ||
| 441 | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | 459 | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | ||
| 442 | remaining -= consumed | 460 | remaining -= consumed | ||
M internal/usecase/daemon/status_test.go +101 -8
| @@ -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 | ||
| @@ -162,19 +163,19 @@ func TestFitTabLabels(t *testing.T) { | |||
| 162 | 163 | tabs []statusTab | |
| 163 | 164 | rowLen int | |
| 164 | 165 | rightText string | |
| 165 | - | want []string | |
| 166 | + | want []fittedTabLabel | |
| 166 | 167 | }{ | |
| 167 | 168 | { | |
| 168 | 169 | name: "no tabs", | |
| 169 | 170 | tabs: nil, | |
| 170 | 171 | rowLen: 10, | |
| 171 | - | want: []string{}, | |
| 172 | + | want: []fittedTabLabel{}, | |
| 172 | 173 | }, | |
| 173 | 174 | { | |
| 174 | 175 | name: "everything fits stays untruncated", | |
| 175 | 176 | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | |
| 176 | 177 | rowLen: 40, | |
| 177 | - | want: []string{"one", "two (shell)"}, | |
| 178 | + | want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}}, | |
| 178 | 179 | }, | |
| 179 | 180 | { | |
| 180 | 181 | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | |
| @@ -183,26 +184,26 @@ func TestFitTabLabels(t *testing.T) { | |||
| 183 | 184 | {name: "ed", paneTitle: "verylongtitle"}, | |
| 184 | 185 | }, | |
| 185 | 186 | rowLen: 20, | |
| 186 | - | want: []string{"a", "ed (verylongt…"}, | |
| 187 | + | want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}}, | |
| 187 | 188 | }, | |
| 188 | 189 | { | |
| 189 | 190 | name: "tight budget degrades to a truncated tab name only", | |
| 190 | 191 | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | |
| 191 | 192 | rowLen: 7, | |
| 192 | - | want: []string{"edi…"}, | |
| 193 | + | want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}}, | |
| 193 | 194 | }, | |
| 194 | 195 | { | |
| 195 | 196 | name: "non-empty rightText shrinks the budget", | |
| 196 | 197 | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | |
| 197 | 198 | rowLen: 20, | |
| 198 | 199 | rightText: "0123456789", | |
| 199 | - | want: []string{"aaaaa…"}, | |
| 200 | + | want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}}, | |
| 200 | 201 | }, | |
| 201 | 202 | { | |
| 202 | 203 | name: "single tab wider than the whole row collapses to empty", | |
| 203 | 204 | tabs: []statusTab{{name: "reallyverylongtabname"}}, | |
| 204 | 205 | rowLen: 3, | |
| 205 | - | want: []string{""}, | |
| 206 | + | want: []fittedTabLabel{{}}, | |
| 206 | 207 | }, | |
| 207 | 208 | } | |
| 208 | 209 | for _, tt := range tests { | |
| @@ -222,7 +223,11 @@ func TestFitTabLabels(t *testing.T) { | |||
| 222 | 223 | if tb.attention { | |
| 223 | 224 | overhead += 1 + renderer.RuneWidth(attentionGlyph) | |
| 224 | 225 | } | |
| 225 | - | drawn += overhead + statusTextWidth(got[i]) | |
| 226 | + | drawn += overhead + statusTextWidth(got[i].text) | |
| 227 | + | require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length") | |
| 228 | + | if got[i].nameLen < len(got[i].text) { | |
| 229 | + | require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary") | |
| 230 | + | } | |
| 226 | 231 | } | |
| 227 | 232 | if budget > 0 { | |
| 228 | 233 | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | |
| @@ -231,6 +236,94 @@ func TestFitTabLabels(t *testing.T) { | |||
| 231 | 236 | } | |
| 232 | 237 | } | |
| 233 | 238 | ||
| 239 | + | func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |
| 240 | + | tabs := []statusTab{ | |
| 241 | + | {name: "api", paneTitle: "shell"}, | |
| 242 | + | {name: "logs", active: true, attention: true}, | |
| 243 | + | } | |
| 244 | + | status := statusSnapshot{tabs: tabs} | |
| 245 | + | const rowLen = 40 | |
| 246 | + | ||
| 247 | + | t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) { | |
| 248 | + | theme := themeui.Theme{ | |
| 249 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 250 | + | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 251 | + | HasFG: true, | |
| 252 | + | HasBG: true, | |
| 253 | + | Known: true, | |
| 254 | + | TrueColor: true, | |
| 255 | + | } | |
| 256 | + | styles := newThemeStyles(theme) | |
| 257 | + | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |
| 258 | + | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | |
| 259 | + | ||
| 260 | + | row := make([]renderer.Cell, rowLen) | |
| 261 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |
| 262 | + | ||
| 263 | + | labels := fitTabLabels(tabs, rowLen, "") | |
| 264 | + | x := 0 | |
| 265 | + | ||
| 266 | + | // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain. | |
| 267 | + | nameEnd := x + 1 + labels[0].nameLen | |
| 268 | + | for i := x; i < nameEnd; i++ { | |
| 269 | + | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | |
| 270 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | |
| 271 | + | } | |
| 272 | + | titleLen := len(labels[0].text) - labels[0].nameLen | |
| 273 | + | titleEnd := nameEnd + titleLen | |
| 274 | + | for i := nameEnd; i < titleEnd; i++ { | |
| 275 | + | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | |
| 276 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | |
| 277 | + | } | |
| 278 | + | x = titleEnd | |
| 279 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x) | |
| 280 | + | x++ | |
| 281 | + | ||
| 282 | + | // tab 1: active, "logs" bold, no title, attention space + bell + trailing space. | |
| 283 | + | nameEnd = x + 1 + labels[1].nameLen | |
| 284 | + | for i := x; i < nameEnd; i++ { | |
| 285 | + | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | |
| 286 | + | require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i) | |
| 287 | + | } | |
| 288 | + | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | |
| 289 | + | x = nameEnd | |
| 290 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: attention leading space must keep the active base style", x) | |
| 291 | + | x += 2 // skip the leading space (checked above) and the bell glyph itself, which pulseStyle owns | |
| 292 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: trailing space must keep the active base style", x) | |
| 293 | + | }) | |
| 294 | + | ||
| 295 | + | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | |
| 296 | + | styles := resolveThemeStyles(nil) | |
| 297 | + | require.True(t, styles.tabName.Equal(styles.statusBar)) | |
| 298 | + | require.True(t, styles.tabTitle.Equal(styles.statusBar)) | |
| 299 | + | require.True(t, styles.tabNameActive.Equal(styles.accent)) | |
| 300 | + | require.True(t, styles.tabTitleActive.Equal(styles.accent)) | |
| 301 | + | ||
| 302 | + | row := make([]renderer.Cell, rowLen) | |
| 303 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |
| 304 | + | ||
| 305 | + | // " api (shell) " (tab 0, incl. trailing space) + " logs" + attention | |
| 306 | + | // space + blank bell (frame 0 is not visible) + trailing space, then | |
| 307 | + | // blank padding to rowLen: byte-for-byte what the pre-change renderer | |
| 308 | + | // produced, since every new style field collapses to statusBar/accent. | |
| 309 | + | const tab0 = " api (shell) " | |
| 310 | + | const tab1 = " logs " | |
| 311 | + | require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row)) | |
| 312 | + | ||
| 313 | + | for i, c := range row[:len(tab0)] { | |
| 314 | + | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | |
| 315 | + | } | |
| 316 | + | activeStart := len(tab0) | |
| 317 | + | bellCell := activeStart + len(" logs") + 1 // pulseStyle-owned, not asserted here | |
| 318 | + | for i := activeStart; i < activeStart+len(tab1); i++ { | |
| 319 | + | if i == bellCell { | |
| 320 | + | continue | |
| 321 | + | } | |
| 322 | + | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style", i) | |
| 323 | + | } | |
| 324 | + | }) | |
| 325 | + | } | |
| 326 | + | ||
| 234 | 327 | func TestTabDisplayName(t *testing.T) { | |
| 235 | 328 | tests := []struct { | |
| 236 | 329 | name string | |
| @@ -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 | ||||
| @@ -162,19 +163,19 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 162 | tabs []statusTab | 163 | tabs []statusTab | ||
| 163 | rowLen int | 164 | rowLen int | ||
| 164 | rightText string | 165 | rightText string | ||
| 165 | - | want []string | 166 | + | want []fittedTabLabel |
| 166 | }{ | 167 | }{ | ||
| 167 | { | 168 | { | ||
| 168 | name: "no tabs", | 169 | name: "no tabs", | ||
| 169 | tabs: nil, | 170 | tabs: nil, | ||
| 170 | rowLen: 10, | 171 | rowLen: 10, | ||
| 171 | - | want: []string{}, | 172 | + | want: []fittedTabLabel{}, |
| 172 | }, | 173 | }, | ||
| 173 | { | 174 | { | ||
| 174 | name: "everything fits stays untruncated", | 175 | name: "everything fits stays untruncated", | ||
| 175 | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | 176 | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | ||
| 176 | rowLen: 40, | 177 | rowLen: 40, | ||
| 177 | - | want: []string{"one", "two (shell)"}, | 178 | + | want: []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}}, |
| 178 | }, | 179 | }, | ||
| 179 | { | 180 | { | ||
| 180 | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | 181 | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | ||
| @@ -183,26 +184,26 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 183 | {name: "ed", paneTitle: "verylongtitle"}, | 184 | {name: "ed", paneTitle: "verylongtitle"}, | ||
| 184 | }, | 185 | }, | ||
| 185 | rowLen: 20, | 186 | rowLen: 20, | ||
| 186 | - | want: []string{"a", "ed (verylongt…"}, | 187 | + | want: []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}}, |
| 187 | }, | 188 | }, | ||
| 188 | { | 189 | { | ||
| 189 | name: "tight budget degrades to a truncated tab name only", | 190 | name: "tight budget degrades to a truncated tab name only", | ||
| 190 | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | 191 | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | ||
| 191 | rowLen: 7, | 192 | rowLen: 7, | ||
| 192 | - | want: []string{"edi…"}, | 193 | + | want: []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}}, |
| 193 | }, | 194 | }, | ||
| 194 | { | 195 | { | ||
| 195 | name: "non-empty rightText shrinks the budget", | 196 | name: "non-empty rightText shrinks the budget", | ||
| 196 | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | 197 | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | ||
| 197 | rowLen: 20, | 198 | rowLen: 20, | ||
| 198 | rightText: "0123456789", | 199 | rightText: "0123456789", | ||
| 199 | - | want: []string{"aaaaa…"}, | 200 | + | want: []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}}, |
| 200 | }, | 201 | }, | ||
| 201 | { | 202 | { | ||
| 202 | name: "single tab wider than the whole row collapses to empty", | 203 | name: "single tab wider than the whole row collapses to empty", | ||
| 203 | tabs: []statusTab{{name: "reallyverylongtabname"}}, | 204 | tabs: []statusTab{{name: "reallyverylongtabname"}}, | ||
| 204 | rowLen: 3, | 205 | rowLen: 3, | ||
| 205 | - | want: []string{""}, | 206 | + | want: []fittedTabLabel{{}}, |
| 206 | }, | 207 | }, | ||
| 207 | } | 208 | } | ||
| 208 | for _, tt := range tests { | 209 | for _, tt := range tests { | ||
| @@ -222,7 +223,11 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 222 | if tb.attention { | 223 | if tb.attention { | ||
| 223 | overhead += 1 + renderer.RuneWidth(attentionGlyph) | 224 | overhead += 1 + renderer.RuneWidth(attentionGlyph) | ||
| 224 | } | 225 | } | ||
| 225 | - | drawn += overhead + statusTextWidth(got[i]) | 226 | + | drawn += overhead + statusTextWidth(got[i].text) |
| 227 | + | require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length") | |||
| 228 | + | if got[i].nameLen < len(got[i].text) { | |||
| 229 | + | require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary") | |||
| 230 | + | } | |||
| 226 | } | 231 | } | ||
| 227 | if budget > 0 { | 232 | if budget > 0 { | ||
| 228 | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | 233 | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | ||
| @@ -231,6 +236,94 @@ func TestFitTabLabels(t *testing.T) { | |||||
| 231 | } | 236 | } | ||
| 232 | } | 237 | } | ||
| 233 | 238 | ||||
| 239 | + | func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 240 | + | tabs := []statusTab{ | |||
| 241 | + | {name: "api", paneTitle: "shell"}, | |||
| 242 | + | {name: "logs", active: true, attention: true}, | |||
| 243 | + | } | |||
| 244 | + | status := statusSnapshot{tabs: tabs} | |||
| 245 | + | const rowLen = 40 | |||
| 246 | + | ||||
| 247 | + | t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) { | |||
| 248 | + | theme := themeui.Theme{ | |||
| 249 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 250 | + | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |||
| 251 | + | HasFG: true, | |||
| 252 | + | HasBG: true, | |||
| 253 | + | Known: true, | |||
| 254 | + | TrueColor: true, | |||
| 255 | + | } | |||
| 256 | + | styles := newThemeStyles(theme) | |||
| 257 | + | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |||
| 258 | + | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | |||
| 259 | + | ||||
| 260 | + | row := make([]renderer.Cell, rowLen) | |||
| 261 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |||
| 262 | + | ||||
| 263 | + | labels := fitTabLabels(tabs, rowLen, "") | |||
| 264 | + | x := 0 | |||
| 265 | + | ||||
| 266 | + | // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain. | |||
| 267 | + | nameEnd := x + 1 + labels[0].nameLen | |||
| 268 | + | for i := x; i < nameEnd; i++ { | |||
| 269 | + | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | |||
| 270 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | |||
| 271 | + | } | |||
| 272 | + | titleLen := len(labels[0].text) - labels[0].nameLen | |||
| 273 | + | titleEnd := nameEnd + titleLen | |||
| 274 | + | for i := nameEnd; i < titleEnd; i++ { | |||
| 275 | + | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | |||
| 276 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | |||
| 277 | + | } | |||
| 278 | + | x = titleEnd | |||
| 279 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x) | |||
| 280 | + | x++ | |||
| 281 | + | ||||
| 282 | + | // tab 1: active, "logs" bold, no title, attention space + bell + trailing space. | |||
| 283 | + | nameEnd = x + 1 + labels[1].nameLen | |||
| 284 | + | for i := x; i < nameEnd; i++ { | |||
| 285 | + | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | |||
| 286 | + | require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i) | |||
| 287 | + | } | |||
| 288 | + | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | |||
| 289 | + | x = nameEnd | |||
| 290 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: attention leading space must keep the active base style", x) | |||
| 291 | + | x += 2 // skip the leading space (checked above) and the bell glyph itself, which pulseStyle owns | |||
| 292 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: trailing space must keep the active base style", x) | |||
| 293 | + | }) | |||
| 294 | + | ||||
| 295 | + | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | |||
| 296 | + | styles := resolveThemeStyles(nil) | |||
| 297 | + | require.True(t, styles.tabName.Equal(styles.statusBar)) | |||
| 298 | + | require.True(t, styles.tabTitle.Equal(styles.statusBar)) | |||
| 299 | + | require.True(t, styles.tabNameActive.Equal(styles.accent)) | |||
| 300 | + | require.True(t, styles.tabTitleActive.Equal(styles.accent)) | |||
| 301 | + | ||||
| 302 | + | row := make([]renderer.Cell, rowLen) | |||
| 303 | + | drawTopBarSnapshot(row, status, 0, "", styles) | |||
| 304 | + | ||||
| 305 | + | // " api (shell) " (tab 0, incl. trailing space) + " logs" + attention | |||
| 306 | + | // space + blank bell (frame 0 is not visible) + trailing space, then | |||
| 307 | + | // blank padding to rowLen: byte-for-byte what the pre-change renderer | |||
| 308 | + | // produced, since every new style field collapses to statusBar/accent. | |||
| 309 | + | const tab0 = " api (shell) " | |||
| 310 | + | const tab1 = " logs " | |||
| 311 | + | require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row)) | |||
| 312 | + | ||||
| 313 | + | for i, c := range row[:len(tab0)] { | |||
| 314 | + | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | |||
| 315 | + | } | |||
| 316 | + | activeStart := len(tab0) | |||
| 317 | + | bellCell := activeStart + len(" logs") + 1 // pulseStyle-owned, not asserted here | |||
| 318 | + | for i := activeStart; i < activeStart+len(tab1); i++ { | |||
| 319 | + | if i == bellCell { | |||
| 320 | + | continue | |||
| 321 | + | } | |||
| 322 | + | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style", i) | |||
| 323 | + | } | |||
| 324 | + | }) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 234 | func TestTabDisplayName(t *testing.T) { | 327 | func TestTabDisplayName(t *testing.T) { | ||
| 235 | tests := []struct { | 328 | tests := []struct { | ||
| 236 | name string | 329 | name string | ||
M internal/usecase/picker/model.go +81 -29
| @@ -17,11 +17,43 @@ 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 after 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 | + | ||
| 52 | + | // attentionGlyph mirrors the bell icon internal/usecase/daemon draws beside | |
| 53 | + | // tab/session labels with a pending attention event. Duplicated here since | |
| 54 | + | // picker cannot import daemon. | |
| 55 | + | const attentionGlyph = '' | |
| 56 | + | ||
| 25 | 57 | type Target struct { | |
| 26 | 58 | Session domain.SessionID | |
| 27 | 59 | Name string | |
| @@ -46,30 +78,34 @@ type Model struct { | |||
| 46 | 78 | } | |
| 47 | 79 | ||
| 48 | 80 | type row struct { | |
| 49 | - | header bool | |
| 50 | - | label string | |
| 51 | - | session domain.SessionID | |
| 52 | - | name string | |
| 53 | - | tabIndex int | |
| 54 | - | stopped bool | |
| 81 | + | header bool | |
| 82 | + | dispName string // display name segment; bold on truecolor | |
| 83 | + | detail string // " (paneTitle)" segment, muted; "" for headers | |
| 84 | + | attention bool // draw the attention marker after detail; tab rows only | |
| 85 | + | session domain.SessionID | |
| 86 | + | // targetName is the stopped-session lookup name threaded into Target; | |
| 87 | + | // distinct from dispName, which is what gets drawn. | |
| 88 | + | targetName string | |
| 89 | + | tabIndex int | |
| 90 | + | stopped bool | |
| 55 | 91 | } | |
| 56 | 92 | ||
| 57 | 93 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |
| 58 | 94 | m := &Model{selected: -1} | |
| 59 | 95 | activeSelection := -1 | |
| 60 | 96 | for _, session := range sessions { | |
| 61 | - | name := "" | |
| 97 | + | targetName := "" | |
| 62 | 98 | if session.Stopped { | |
| 63 | - | name = session.Name | |
| 99 | + | targetName = session.Name | |
| 64 | 100 | } | |
| 65 | - | m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped}) | |
| 101 | + | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped}) | |
| 66 | 102 | active := session.Active | |
| 67 | 103 | if active < 0 || active >= len(session.Tabs) { | |
| 68 | 104 | active = 0 | |
| 69 | 105 | } | |
| 70 | 106 | for i, tab := range session.Tabs { | |
| 71 | 107 | idx := len(m.rows) | |
| 72 | - | m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped}) | |
| 108 | + | 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 | 109 | if session.ID == cur && i == curTab { | |
| 74 | 110 | m.selected = idx | |
| 75 | 111 | } | |
| @@ -131,7 +167,7 @@ func (m *Model) Selected() (Target, bool) { | |||
| 131 | 167 | if r.header { | |
| 132 | 168 | return Target{}, false | |
| 133 | 169 | } | |
| 134 | - | return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true | |
| 170 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true | |
| 135 | 171 | } | |
| 136 | 172 | ||
| 137 | 173 | func (m *Model) Clone() *Model { | |
| @@ -143,15 +179,14 @@ func (m *Model) Clone() *Model { | |||
| 143 | 179 | return &clone | |
| 144 | 180 | } | |
| 145 | 181 | ||
| 146 | - | func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame { | |
| 182 | + | func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame { | |
| 147 | 183 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 148 | 184 | layout := ChooseLayout(inner) | |
| 149 | - | selection := renderer.DefaultStyle() | |
| 150 | - | selection.Inverse = true | |
| 151 | - | if len(selectedStyle) > 0 { | |
| 152 | - | selection = selectedStyle[0] | |
| 185 | + | styleSet := defaultRenderStyles() | |
| 186 | + | if len(styles) > 0 { | |
| 187 | + | styleSet = styles[0] | |
| 153 | 188 | } | |
| 154 | - | m.renderList(frame, layout.List, selection) | |
| 189 | + | m.renderList(frame, layout.List, styleSet) | |
| 155 | 190 | blitPreview(frame, layout.Preview, preview) | |
| 156 | 191 | return frame | |
| 157 | 192 | } | |
| @@ -181,32 +216,49 @@ func (m *Model) firstLeaf() int { | |||
| 181 | 216 | return -1 | |
| 182 | 217 | } | |
| 183 | 218 | ||
| 184 | - | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) { | |
| 219 | + | // renderList draws each visible row as up to three segments: a name segment | |
| 220 | + | // (bold when styles came from a truecolor theme), a muted detail segment | |
| 221 | + | // (tab rows only), and a base-styled attention marker or "(stopped)" suffix. | |
| 222 | + | // A tight width ellipsizes the detail segment before eating into the name. | |
| 223 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | |
| 185 | 224 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 186 | 225 | return | |
| 187 | 226 | } | |
| 188 | 227 | visible := min(rect.Height, frame.Height-rect.Y) | |
| 189 | 228 | offset := m.scrollOffset(visible) | |
| 229 | + | clipX := rect.X + rect.Width | |
| 190 | 230 | for y := range visible { | |
| 191 | 231 | idx := offset + y | |
| 192 | 232 | if idx >= len(m.rows) { | |
| 193 | 233 | break | |
| 194 | 234 | } | |
| 195 | 235 | r := m.rows[idx] | |
| 196 | - | style := renderer.DefaultStyle() | |
| 236 | + | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | |
| 197 | 237 | if idx == m.selected { | |
| 198 | - | style = selection | |
| 199 | - | } | |
| 200 | - | label := r.label | |
| 201 | - | if r.header && r.stopped { | |
| 202 | - | label += " (stopped)" | |
| 238 | + | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | |
| 203 | 239 | } | |
| 240 | + | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |
| 241 | + | ||
| 242 | + | name := r.dispName | |
| 204 | 243 | if !r.header { | |
| 205 | - | label = " " + label | |
| 244 | + | name = " " + name | |
| 245 | + | } | |
| 246 | + | name = ui.TruncateText(name, rect.Width) | |
| 247 | + | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | |
| 248 | + | ||
| 249 | + | if r.header { | |
| 250 | + | if r.stopped { | |
| 251 | + | ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base) | |
| 252 | + | } | |
| 253 | + | continue | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | detail := ui.TruncateText(r.detail, clipX-x) | |
| 257 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |
| 258 | + | ||
| 259 | + | if r.attention { | |
| 260 | + | ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base) | |
| 206 | 261 | } | |
| 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) | |
| 210 | 262 | } | |
| 211 | 263 | } | |
| 212 | 264 | ||
| @@ -17,11 +17,43 @@ 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 after 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 | + | ||||
| 52 | + | // attentionGlyph mirrors the bell icon internal/usecase/daemon draws beside | |||
| 53 | + | // tab/session labels with a pending attention event. Duplicated here since | |||
| 54 | + | // picker cannot import daemon. | |||
| 55 | + | const attentionGlyph = '' | |||
| 56 | + | ||||
| 25 | type Target struct { | 57 | type Target struct { | ||
| 26 | Session domain.SessionID | 58 | Session domain.SessionID | ||
| 27 | Name string | 59 | Name string | ||
| @@ -46,30 +78,34 @@ type Model struct { | |||||
| 46 | } | 78 | } | ||
| 47 | 79 | ||||
| 48 | type row struct { | 80 | type row struct { | ||
| 49 | - | header bool | 81 | + | header bool |
| 50 | - | label string | 82 | + | dispName string // display name segment; bold on truecolor |
| 51 | - | session domain.SessionID | 83 | + | detail string // " (paneTitle)" segment, muted; "" for headers |
| 52 | - | name string | 84 | + | attention bool // draw the attention marker after detail; tab rows only |
| 53 | - | tabIndex int | 85 | + | session domain.SessionID |
| 54 | - | stopped bool | 86 | + | // targetName is the stopped-session lookup name threaded into Target; |
| 87 | + | // distinct from dispName, which is what gets drawn. | |||
| 88 | + | targetName string | |||
| 89 | + | tabIndex int | |||
| 90 | + | stopped bool | |||
| 55 | } | 91 | } | ||
| 56 | 92 | ||||
| 57 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | 93 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | ||
| 58 | m := &Model{selected: -1} | 94 | m := &Model{selected: -1} | ||
| 59 | activeSelection := -1 | 95 | activeSelection := -1 | ||
| 60 | for _, session := range sessions { | 96 | for _, session := range sessions { | ||
| 61 | - | name := "" | 97 | + | targetName := "" |
| 62 | if session.Stopped { | 98 | if session.Stopped { | ||
| 63 | - | name = session.Name | 99 | + | targetName = session.Name |
| 64 | } | 100 | } | ||
| 65 | - | m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped}) | 101 | + | 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 | 102 | active := session.Active | ||
| 67 | if active < 0 || active >= len(session.Tabs) { | 103 | if active < 0 || active >= len(session.Tabs) { | ||
| 68 | active = 0 | 104 | active = 0 | ||
| 69 | } | 105 | } | ||
| 70 | for i, tab := range session.Tabs { | 106 | for i, tab := range session.Tabs { | ||
| 71 | idx := len(m.rows) | 107 | idx := len(m.rows) | ||
| 72 | - | m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped}) | 108 | + | 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 { | 109 | if session.ID == cur && i == curTab { | ||
| 74 | m.selected = idx | 110 | m.selected = idx | ||
| 75 | } | 111 | } | ||
| @@ -131,7 +167,7 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 131 | if r.header { | 167 | if r.header { | ||
| 132 | return Target{}, false | 168 | return Target{}, false | ||
| 133 | } | 169 | } | ||
| 134 | - | return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true | 170 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true |
| 135 | } | 171 | } | ||
| 136 | 172 | ||||
| 137 | func (m *Model) Clone() *Model { | 173 | func (m *Model) Clone() *Model { | ||
| @@ -143,15 +179,14 @@ func (m *Model) Clone() *Model { | |||||
| 143 | return &clone | 179 | return &clone | ||
| 144 | } | 180 | } | ||
| 145 | 181 | ||||
| 146 | - | func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame { | 182 | + | 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)) | 183 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 148 | layout := ChooseLayout(inner) | 184 | layout := ChooseLayout(inner) | ||
| 149 | - | selection := renderer.DefaultStyle() | 185 | + | styleSet := defaultRenderStyles() |
| 150 | - | selection.Inverse = true | 186 | + | if len(styles) > 0 { |
| 151 | - | if len(selectedStyle) > 0 { | 187 | + | styleSet = styles[0] |
| 152 | - | selection = selectedStyle[0] | |||
| 153 | } | 188 | } | ||
| 154 | - | m.renderList(frame, layout.List, selection) | 189 | + | m.renderList(frame, layout.List, styleSet) |
| 155 | blitPreview(frame, layout.Preview, preview) | 190 | blitPreview(frame, layout.Preview, preview) | ||
| 156 | return frame | 191 | return frame | ||
| 157 | } | 192 | } | ||
| @@ -181,32 +216,49 @@ func (m *Model) firstLeaf() int { | |||||
| 181 | return -1 | 216 | return -1 | ||
| 182 | } | 217 | } | ||
| 183 | 218 | ||||
| 184 | - | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) { | 219 | + | // renderList draws each visible row as up to three segments: a name segment |
| 220 | + | // (bold when styles came from a truecolor theme), a muted detail segment | |||
| 221 | + | // (tab rows only), and a base-styled attention marker or "(stopped)" suffix. | |||
| 222 | + | // A tight width ellipsizes the detail segment before eating into the name. | |||
| 223 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | |||
| 185 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | 224 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | ||
| 186 | return | 225 | return | ||
| 187 | } | 226 | } | ||
| 188 | visible := min(rect.Height, frame.Height-rect.Y) | 227 | visible := min(rect.Height, frame.Height-rect.Y) | ||
| 189 | offset := m.scrollOffset(visible) | 228 | offset := m.scrollOffset(visible) | ||
| 229 | + | clipX := rect.X + rect.Width | |||
| 190 | for y := range visible { | 230 | for y := range visible { | ||
| 191 | idx := offset + y | 231 | idx := offset + y | ||
| 192 | if idx >= len(m.rows) { | 232 | if idx >= len(m.rows) { | ||
| 193 | break | 233 | break | ||
| 194 | } | 234 | } | ||
| 195 | r := m.rows[idx] | 235 | r := m.rows[idx] | ||
| 196 | - | style := renderer.DefaultStyle() | 236 | + | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail |
| 197 | if idx == m.selected { | 237 | if idx == m.selected { | ||
| 198 | - | style = selection | 238 | + | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted |
| 199 | - | } | |||
| 200 | - | label := r.label | |||
| 201 | - | if r.header && r.stopped { | |||
| 202 | - | label += " (stopped)" | |||
| 203 | } | 239 | } | ||
| 240 | + | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |||
| 241 | + | ||||
| 242 | + | name := r.dispName | |||
| 204 | if !r.header { | 243 | if !r.header { | ||
| 205 | - | label = " " + label | 244 | + | name = " " + name |
| 245 | + | } | |||
| 246 | + | name = ui.TruncateText(name, rect.Width) | |||
| 247 | + | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | |||
| 248 | + | ||||
| 249 | + | if r.header { | |||
| 250 | + | if r.stopped { | |||
| 251 | + | ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base) | |||
| 252 | + | } | |||
| 253 | + | continue | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | detail := ui.TruncateText(r.detail, clipX-x) | |||
| 257 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |||
| 258 | + | ||||
| 259 | + | if r.attention { | |||
| 260 | + | ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base) | |||
| 206 | } | 261 | } | ||
| 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) | |||
| 210 | } | 262 | } | ||
| 211 | } | 263 | } | ||
| 212 | 264 | ||||
M internal/usecase/picker/model_test.go +85 -13
| @@ -10,8 +10,8 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |
| 12 | 12 | m := New([]SessionView{ | |
| 13 | - | {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0}, | |
| 14 | - | {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0}, | |
| 13 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0}, | |
| 14 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0}, | |
| 15 | 15 | }, "s1", 1) | |
| 16 | 16 | ||
| 17 | 17 | got, ok := m.Selected() | |
| @@ -25,12 +25,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |
| 28 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0) | |
| 28 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0) | |
| 29 | 29 | got, ok := m.Selected() | |
| 30 | 30 | require.True(t, ok) | |
| 31 | 31 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |
| 32 | 32 | ||
| 33 | - | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0) | |
| 33 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0) | |
| 34 | 34 | got, ok = m.Selected() | |
| 35 | 35 | require.True(t, ok) | |
| 36 | 36 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | |
| @@ -38,8 +38,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |||
| 38 | 38 | ||
| 39 | 39 | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |
| 40 | 40 | m := New([]SessionView{ | |
| 41 | - | {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0}, | |
| 42 | - | {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0}, | |
| 41 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0}, | |
| 42 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0}, | |
| 43 | 43 | }, "s1", 0) | |
| 44 | 44 | ||
| 45 | 45 | m.Up() | |
| @@ -92,8 +92,8 @@ func TestChooseLayoutBoundaries(t *testing.T) { | |||
| 92 | 92 | ||
| 93 | 93 | func TestSelectedMapping(t *testing.T) { | |
| 94 | 94 | m := New([]SessionView{ | |
| 95 | - | {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0}, | |
| 96 | - | {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0}, | |
| 95 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |
| 96 | + | {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0}, | |
| 97 | 97 | }, "beta", 1) | |
| 98 | 98 | ||
| 99 | 99 | got, ok := m.Selected() | |
| @@ -102,7 +102,7 @@ func TestSelectedMapping(t *testing.T) { | |||
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |
| 105 | - | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0) | |
| 105 | + | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0) | |
| 106 | 106 | got, ok := m.Selected() | |
| 107 | 107 | require.True(t, ok) | |
| 108 | 108 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | |
| @@ -112,7 +112,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 115 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 115 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 116 | 116 | preview := Preview{ | |
| 117 | 117 | Width: 24, | |
| 118 | 118 | Height: 1, | |
| @@ -132,7 +132,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 134 | 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) | |
| 135 | + | 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 | 136 | for range 5 { | |
| 137 | 137 | m.Down() | |
| 138 | 138 | } | |
| @@ -144,7 +144,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | 146 | 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) | |
| 147 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) | |
| 148 | 148 | ||
| 149 | 149 | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 150 | 150 | ||
| @@ -155,7 +155,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| 158 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 158 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 159 | 159 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | |
| 160 | 160 | ||
| 161 | 161 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | |
| @@ -164,6 +164,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |||
| 164 | 164 | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | + | func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |
| 168 | + | nameStyle := renderer.Style{Bold: true} | |
| 169 | + | detailStyle := renderer.Style{Italic: true} | |
| 170 | + | baseStyle := renderer.DefaultStyle() | |
| 171 | + | selectionStyle := renderer.Style{Inverse: true} | |
| 172 | + | selectionNameStyle := renderer.Style{Inverse: true, Bold: true} | |
| 173 | + | selectionMutedStyle := renderer.Style{Inverse: true, Italic: true} | |
| 174 | + | styles := RenderStyles{ | |
| 175 | + | Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle, | |
| 176 | + | Name: nameStyle, Detail: detailStyle, Base: baseStyle, | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 180 | + | {Name: "alpha", Detail: " (running)"}, | |
| 181 | + | {Name: "beta", Detail: " (idle)", Attention: true}, | |
| 182 | + | }, Active: 0}}, "s1", 0) | |
| 183 | + | ||
| 184 | + | frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles) | |
| 185 | + | ||
| 186 | + | // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted. | |
| 187 | + | require.Equal(t, 'a', frame.At(2, 1).Rune) | |
| 188 | + | require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style") | |
| 189 | + | require.Equal(t, ' ', frame.At(7, 1).Rune) | |
| 190 | + | require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |
| 191 | + | require.Equal(t, '(', frame.At(8, 1).Rune) | |
| 192 | + | require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |
| 193 | + | ||
| 194 | + | // Row 2 is the non-selected tab ("beta"): name uses Name, detail uses | |
| 195 | + | // Detail, and the attention marker after the detail uses the row's base | |
| 196 | + | // style (not muted). | |
| 197 | + | require.Equal(t, 'b', frame.At(2, 2).Rune) | |
| 198 | + | require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style") | |
| 199 | + | require.Equal(t, ' ', frame.At(6, 2).Rune) | |
| 200 | + | require.True(t, frame.At(6, 2).Style.Equal(detailStyle), "detail segment style") | |
| 201 | + | require.Equal(t, '(', frame.At(7, 2).Rune) | |
| 202 | + | require.True(t, frame.At(7, 2).Style.Equal(detailStyle), "detail segment style") | |
| 203 | + | require.Equal(t, ' ', frame.At(13, 2).Rune, "attention marker leading space") | |
| 204 | + | require.True(t, frame.At(13, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 205 | + | require.Equal(t, rune(attentionGlyph), frame.At(14, 2).Rune) | |
| 206 | + | require.True(t, frame.At(14, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |
| 210 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 211 | + | {Name: "short-name", Detail: " (a very long detail text)"}, | |
| 212 | + | }, Active: 0}}, "s1", 0) | |
| 213 | + | ||
| 214 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 215 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 216 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 217 | + | ||
| 218 | + | want := " short-name (a…" | |
| 219 | + | for i, r := range want { | |
| 220 | + | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | |
| 221 | + | } | |
| 222 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched") | |
| 223 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width") | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |
| 227 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 228 | + | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | |
| 229 | + | }, Active: 0}}, "s1", 0) | |
| 230 | + | ||
| 231 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 232 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 233 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 234 | + | ||
| 235 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | |
| 236 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all") | |
| 237 | + | } | |
| 238 | + | ||
| 167 | 239 | func cell(r rune) renderer.Cell { | |
| 168 | 240 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 169 | 241 | } | |
| @@ -10,8 +10,8 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | 11 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | ||
| 12 | m := New([]SessionView{ | 12 | m := New([]SessionView{ | ||
| 13 | - | {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0}, | 13 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0}, |
| 14 | - | {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0}, | 14 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0}, |
| 15 | }, "s1", 1) | 15 | }, "s1", 1) | ||
| 16 | 16 | ||||
| 17 | got, ok := m.Selected() | 17 | got, ok := m.Selected() | ||
| @@ -25,12 +25,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |||||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | 27 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | ||
| 28 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0) | 28 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0) |
| 29 | got, ok := m.Selected() | 29 | got, ok := m.Selected() | ||
| 30 | require.True(t, ok) | 30 | require.True(t, ok) | ||
| 31 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | 31 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | ||
| 32 | 32 | ||||
| 33 | - | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0) | 33 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0) |
| 34 | got, ok = m.Selected() | 34 | got, ok = m.Selected() | ||
| 35 | require.True(t, ok) | 35 | require.True(t, ok) | ||
| 36 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | 36 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | ||
| @@ -38,8 +38,8 @@ func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |||||
| 38 | 38 | ||||
| 39 | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | 39 | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | ||
| 40 | m := New([]SessionView{ | 40 | m := New([]SessionView{ | ||
| 41 | - | {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0}, | 41 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0}, |
| 42 | - | {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0}, | 42 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0}, |
| 43 | }, "s1", 0) | 43 | }, "s1", 0) | ||
| 44 | 44 | ||||
| 45 | m.Up() | 45 | m.Up() | ||
| @@ -92,8 +92,8 @@ func TestChooseLayoutBoundaries(t *testing.T) { | |||||
| 92 | 92 | ||||
| 93 | func TestSelectedMapping(t *testing.T) { | 93 | func TestSelectedMapping(t *testing.T) { | ||
| 94 | m := New([]SessionView{ | 94 | m := New([]SessionView{ | ||
| 95 | - | {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0}, | 95 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, |
| 96 | - | {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0}, | 96 | + | {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0}, |
| 97 | }, "beta", 1) | 97 | }, "beta", 1) | ||
| 98 | 98 | ||||
| 99 | got, ok := m.Selected() | 99 | got, ok := m.Selected() | ||
| @@ -102,7 +102,7 @@ func TestSelectedMapping(t *testing.T) { | |||||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | 104 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | ||
| 105 | - | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0) | 105 | + | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0) |
| 106 | got, ok := m.Selected() | 106 | got, ok := m.Selected() | ||
| 107 | require.True(t, ok) | 107 | require.True(t, ok) | ||
| 108 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | 108 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | ||
| @@ -112,7 +112,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | 114 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | ||
| 115 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | 115 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) |
| 116 | preview := Preview{ | 116 | preview := Preview{ | ||
| 117 | Width: 24, | 117 | Width: 24, | ||
| 118 | Height: 1, | 118 | Height: 1, | ||
| @@ -132,7 +132,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||||
| 132 | } | 132 | } | ||
| 133 | 133 | ||||
| 134 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | 134 | 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) | 135 | + | 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 { | 136 | for range 5 { | ||
| 137 | m.Down() | 137 | m.Down() | ||
| 138 | } | 138 | } | ||
| @@ -144,7 +144,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | 146 | 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) | 147 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) |
| 148 | 148 | ||||
| 149 | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | 149 | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | ||
| 150 | 150 | ||||
| @@ -155,7 +155,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||||
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | 157 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | ||
| 158 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | 158 | + | 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')}}} | 159 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | ||
| 160 | 160 | ||||
| 161 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | 161 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | ||
| @@ -164,6 +164,78 @@ func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |||||
| 164 | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | 164 | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | ||
| 165 | } | 165 | } | ||
| 166 | 166 | ||||
| 167 | + | func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||
| 168 | + | nameStyle := renderer.Style{Bold: true} | |||
| 169 | + | detailStyle := renderer.Style{Italic: true} | |||
| 170 | + | baseStyle := renderer.DefaultStyle() | |||
| 171 | + | selectionStyle := renderer.Style{Inverse: true} | |||
| 172 | + | selectionNameStyle := renderer.Style{Inverse: true, Bold: true} | |||
| 173 | + | selectionMutedStyle := renderer.Style{Inverse: true, Italic: true} | |||
| 174 | + | styles := RenderStyles{ | |||
| 175 | + | Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle, | |||
| 176 | + | Name: nameStyle, Detail: detailStyle, Base: baseStyle, | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |||
| 180 | + | {Name: "alpha", Detail: " (running)"}, | |||
| 181 | + | {Name: "beta", Detail: " (idle)", Attention: true}, | |||
| 182 | + | }, Active: 0}}, "s1", 0) | |||
| 183 | + | ||||
| 184 | + | frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles) | |||
| 185 | + | ||||
| 186 | + | // Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted. | |||
| 187 | + | require.Equal(t, 'a', frame.At(2, 1).Rune) | |||
| 188 | + | require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style") | |||
| 189 | + | require.Equal(t, ' ', frame.At(7, 1).Rune) | |||
| 190 | + | require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |||
| 191 | + | require.Equal(t, '(', frame.At(8, 1).Rune) | |||
| 192 | + | require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |||
| 193 | + | ||||
| 194 | + | // Row 2 is the non-selected tab ("beta"): name uses Name, detail uses | |||
| 195 | + | // Detail, and the attention marker after the detail uses the row's base | |||
| 196 | + | // style (not muted). | |||
| 197 | + | require.Equal(t, 'b', frame.At(2, 2).Rune) | |||
| 198 | + | require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style") | |||
| 199 | + | require.Equal(t, ' ', frame.At(6, 2).Rune) | |||
| 200 | + | require.True(t, frame.At(6, 2).Style.Equal(detailStyle), "detail segment style") | |||
| 201 | + | require.Equal(t, '(', frame.At(7, 2).Rune) | |||
| 202 | + | require.True(t, frame.At(7, 2).Style.Equal(detailStyle), "detail segment style") | |||
| 203 | + | require.Equal(t, ' ', frame.At(13, 2).Rune, "attention marker leading space") | |||
| 204 | + | require.True(t, frame.At(13, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |||
| 205 | + | require.Equal(t, rune(attentionGlyph), frame.At(14, 2).Rune) | |||
| 206 | + | require.True(t, frame.At(14, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||
| 210 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |||
| 211 | + | {Name: "short-name", Detail: " (a very long detail text)"}, | |||
| 212 | + | }, Active: 0}}, "s1", 0) | |||
| 213 | + | ||||
| 214 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |||
| 215 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |||
| 216 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |||
| 217 | + | ||||
| 218 | + | want := " short-name (a…" | |||
| 219 | + | for i, r := range want { | |||
| 220 | + | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | |||
| 221 | + | } | |||
| 222 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched") | |||
| 223 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width") | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||
| 227 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |||
| 228 | + | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | |||
| 229 | + | }, Active: 0}}, "s1", 0) | |||
| 230 | + | ||||
| 231 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |||
| 232 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |||
| 233 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |||
| 234 | + | ||||
| 235 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | |||
| 236 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all") | |||
| 237 | + | } | |||
| 238 | + | ||||
| 167 | func cell(r rune) renderer.Cell { | 239 | func cell(r rune) renderer.Cell { | ||
| 168 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | 240 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | ||
| 169 | } | 241 | } | ||
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 | + | } | |||