Merge pull request #39 from bnema/feat/modal-backdrop-dimming
Dim TUI backdrops beneath dialogs
8 files · +217 -15
M packages/agent/modes/interactive.go +44 -2
| @@ -545,6 +545,7 @@ type Interactive struct { | |||
| 545 | 545 | prevChatCols int | |
| 546 | 546 | prevChatRows int | |
| 547 | 547 | prevOverlayOpen bool | |
| 548 | + | cursorDimmed bool | |
| 548 | 549 | ||
| 549 | 550 | // chatCache stores the built transcript/status-note rows for idle | |
| 550 | 551 | // frames. Editor typing changes only the bottom input region, so | |
| @@ -1687,6 +1688,7 @@ func (i *Interactive) redraw() { | |||
| 1687 | 1688 | if len(dialog) > 0 { | |
| 1688 | 1689 | dialog = padDialogFrame(dialog) | |
| 1689 | 1690 | } | |
| 1691 | + | modalBackdrop := len(dialog) > 0 | |
| 1690 | 1692 | ||
| 1691 | 1693 | // Slash-command autocomplete: popup above the status line, only | |
| 1692 | 1694 | // when the editor starts with "/" and no dialog is already open. | |
| @@ -2067,6 +2069,17 @@ func (i *Interactive) redraw() { | |||
| 2067 | 2069 | } | |
| 2068 | 2070 | } | |
| 2069 | 2071 | ||
| 2072 | + | // A dialog is the foreground layer. Dim every other visible surface, | |
| 2073 | + | // including the main input beneath it, without changing the dialog's | |
| 2074 | + | // own styling. Keep the source slices untouched because chat is cached. | |
| 2075 | + | if modalBackdrop { | |
| 2076 | + | chat = tui.DimLines(chat) | |
| 2077 | + | visibleChat = tui.DimLines(visibleChat) | |
| 2078 | + | dimmedBottom := tui.DimLines(bottom) | |
| 2079 | + | copy(dimmedBottom[1:1+len(dialog)], dialog) | |
| 2080 | + | bottom = dimmedBottom | |
| 2081 | + | } | |
| 2082 | + | ||
| 2070 | 2083 | // Default: the real terminal cursor sits on the main editor's | |
| 2071 | 2084 | // input position. In main-screen log mode cursor rows are relative | |
| 2072 | 2085 | // to the fixed bottom band, not the chat transcript. | |
| @@ -2079,22 +2092,26 @@ func (i *Interactive) redraw() { | |||
| 2079 | 2092 | } | |
| 2080 | 2093 | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 2081 | 2094 | cursorCol := curC + inputCursorColOffset | |
| 2095 | + | cursorInDialog := false | |
| 2082 | 2096 | if i.btwDialog.Active() { | |
| 2083 | 2097 | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 2084 | 2098 | cursorRow = dialogLead + r | |
| 2085 | 2099 | cursorCol = c | |
| 2100 | + | cursorInDialog = true | |
| 2086 | 2101 | } | |
| 2087 | 2102 | } | |
| 2088 | 2103 | if i.dialog.Active() { | |
| 2089 | 2104 | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 2090 | 2105 | cursorRow = dialogLead + r | |
| 2091 | 2106 | cursorCol = c | |
| 2107 | + | cursorInDialog = true | |
| 2092 | 2108 | } | |
| 2093 | 2109 | } | |
| 2094 | 2110 | if i.llamaDialog.Active() { | |
| 2095 | 2111 | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | |
| 2096 | 2112 | cursorRow = dialogLead + r | |
| 2097 | 2113 | cursorCol = c | |
| 2114 | + | cursorInDialog = true | |
| 2098 | 2115 | } else { | |
| 2099 | 2116 | cursorRow = -1 | |
| 2100 | 2117 | cursorCol = 0 | |
| @@ -2104,18 +2121,21 @@ func (i *Interactive) redraw() { | |||
| 2104 | 2121 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 2105 | 2122 | cursorRow = dialogLead + r | |
| 2106 | 2123 | cursorCol = c | |
| 2124 | + | cursorInDialog = true | |
| 2107 | 2125 | } | |
| 2108 | 2126 | } | |
| 2109 | 2127 | if i.sessionTreeDialog.Active() { | |
| 2110 | 2128 | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | |
| 2111 | 2129 | cursorRow = dialogLead + r | |
| 2112 | 2130 | cursorCol = c | |
| 2131 | + | cursorInDialog = true | |
| 2113 | 2132 | } | |
| 2114 | 2133 | } | |
| 2115 | 2134 | if i.subagentsDialog.Active() { | |
| 2116 | 2135 | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { | |
| 2117 | 2136 | cursorRow = dialogLead + r | |
| 2118 | 2137 | cursorCol = c | |
| 2138 | + | cursorInDialog = true | |
| 2119 | 2139 | } else { | |
| 2120 | 2140 | // Dashboard list / transcript view has no caret. Without | |
| 2121 | 2141 | // this branch the default cursorRow points at the | |
| @@ -2129,9 +2149,14 @@ func (i *Interactive) redraw() { | |||
| 2129 | 2149 | cursorRow = -1 | |
| 2130 | 2150 | cursorCol = 0 | |
| 2131 | 2151 | } | |
| 2152 | + | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 2132 | 2153 | if rightBarActive { | |
| 2133 | 2154 | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | |
| 2134 | - | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 2155 | + | if modalBackdrop { | |
| 2156 | + | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | |
| 2157 | + | } else { | |
| 2158 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 2159 | + | } | |
| 2135 | 2160 | } else { | |
| 2136 | 2161 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2137 | 2162 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| @@ -3951,11 +3976,28 @@ func onOff(v bool) string { | |||
| 3951 | 3976 | return "disabled" | |
| 3952 | 3977 | } | |
| 3953 | 3978 | ||
| 3979 | + | const modalBackdropDimPercent = 50 | |
| 3980 | + | ||
| 3954 | 3981 | func (i *Interactive) applyInputCursorColor() { | |
| 3955 | 3982 | if i == nil || i.cfg.Terminal == nil { | |
| 3956 | 3983 | return | |
| 3957 | 3984 | } | |
| 3958 | - | _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock())) | |
| 3985 | + | color := tui.Color256(15) | |
| 3986 | + | if i.cursorDimmed { | |
| 3987 | + | color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent) | |
| 3988 | + | } | |
| 3989 | + | _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock())) | |
| 3990 | + | } | |
| 3991 | + | ||
| 3992 | + | // setInputCursorDimmed updates the terminal-owned cursor only when its layer | |
| 3993 | + | // changes. Reapplying cursor controls on every redraw can reset its blink | |
| 3994 | + | // timing before the terminal completes a normal blink cycle. | |
| 3995 | + | func (i *Interactive) setInputCursorDimmed(dimmed bool) { | |
| 3996 | + | if i == nil || i.cursorDimmed == dimmed { | |
| 3997 | + | return | |
| 3998 | + | } | |
| 3999 | + | i.cursorDimmed = dimmed | |
| 4000 | + | i.applyInputCursorColor() | |
| 3959 | 4001 | } | |
| 3960 | 4002 | ||
| 3961 | 4003 | func reasoningSettingOptions(levels []string) []settingsOption { | |
| @@ -545,6 +545,7 @@ type Interactive struct { | |||||
| 545 | prevChatCols int | 545 | prevChatCols int | ||
| 546 | prevChatRows int | 546 | prevChatRows int | ||
| 547 | prevOverlayOpen bool | 547 | prevOverlayOpen bool | ||
| 548 | + | cursorDimmed bool | |||
| 548 | 549 | ||||
| 549 | // chatCache stores the built transcript/status-note rows for idle | 550 | // chatCache stores the built transcript/status-note rows for idle | ||
| 550 | // frames. Editor typing changes only the bottom input region, so | 551 | // frames. Editor typing changes only the bottom input region, so | ||
| @@ -1687,6 +1688,7 @@ func (i *Interactive) redraw() { | |||||
| 1687 | if len(dialog) > 0 { | 1688 | if len(dialog) > 0 { | ||
| 1688 | dialog = padDialogFrame(dialog) | 1689 | dialog = padDialogFrame(dialog) | ||
| 1689 | } | 1690 | } | ||
| 1691 | + | modalBackdrop := len(dialog) > 0 | |||
| 1690 | 1692 | ||||
| 1691 | // Slash-command autocomplete: popup above the status line, only | 1693 | // Slash-command autocomplete: popup above the status line, only | ||
| 1692 | // when the editor starts with "/" and no dialog is already open. | 1694 | // when the editor starts with "/" and no dialog is already open. | ||
| @@ -2067,6 +2069,17 @@ func (i *Interactive) redraw() { | |||||
| 2067 | } | 2069 | } | ||
| 2068 | } | 2070 | } | ||
| 2069 | 2071 | ||||
| 2072 | + | // A dialog is the foreground layer. Dim every other visible surface, | |||
| 2073 | + | // including the main input beneath it, without changing the dialog's | |||
| 2074 | + | // own styling. Keep the source slices untouched because chat is cached. | |||
| 2075 | + | if modalBackdrop { | |||
| 2076 | + | chat = tui.DimLines(chat) | |||
| 2077 | + | visibleChat = tui.DimLines(visibleChat) | |||
| 2078 | + | dimmedBottom := tui.DimLines(bottom) | |||
| 2079 | + | copy(dimmedBottom[1:1+len(dialog)], dialog) | |||
| 2080 | + | bottom = dimmedBottom | |||
| 2081 | + | } | |||
| 2082 | + | ||||
| 2070 | // Default: the real terminal cursor sits on the main editor's | 2083 | // Default: the real terminal cursor sits on the main editor's | ||
| 2071 | // input position. In main-screen log mode cursor rows are relative | 2084 | // input position. In main-screen log mode cursor rows are relative | ||
| 2072 | // to the fixed bottom band, not the chat transcript. | 2085 | // to the fixed bottom band, not the chat transcript. | ||
| @@ -2079,22 +2092,26 @@ func (i *Interactive) redraw() { | |||||
| 2079 | } | 2092 | } | ||
| 2080 | cursorRow := inputStartRow + inputCursorOffset + curR | 2093 | cursorRow := inputStartRow + inputCursorOffset + curR | ||
| 2081 | cursorCol := curC + inputCursorColOffset | 2094 | cursorCol := curC + inputCursorColOffset | ||
| 2095 | + | cursorInDialog := false | |||
| 2082 | if i.btwDialog.Active() { | 2096 | if i.btwDialog.Active() { | ||
| 2083 | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | 2097 | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | ||
| 2084 | cursorRow = dialogLead + r | 2098 | cursorRow = dialogLead + r | ||
| 2085 | cursorCol = c | 2099 | cursorCol = c | ||
| 2100 | + | cursorInDialog = true | |||
| 2086 | } | 2101 | } | ||
| 2087 | } | 2102 | } | ||
| 2088 | if i.dialog.Active() { | 2103 | if i.dialog.Active() { | ||
| 2089 | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | 2104 | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | ||
| 2090 | cursorRow = dialogLead + r | 2105 | cursorRow = dialogLead + r | ||
| 2091 | cursorCol = c | 2106 | cursorCol = c | ||
| 2107 | + | cursorInDialog = true | |||
| 2092 | } | 2108 | } | ||
| 2093 | } | 2109 | } | ||
| 2094 | if i.llamaDialog.Active() { | 2110 | if i.llamaDialog.Active() { | ||
| 2095 | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | 2111 | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | ||
| 2096 | cursorRow = dialogLead + r | 2112 | cursorRow = dialogLead + r | ||
| 2097 | cursorCol = c | 2113 | cursorCol = c | ||
| 2114 | + | cursorInDialog = true | |||
| 2098 | } else { | 2115 | } else { | ||
| 2099 | cursorRow = -1 | 2116 | cursorRow = -1 | ||
| 2100 | cursorCol = 0 | 2117 | cursorCol = 0 | ||
| @@ -2104,18 +2121,21 @@ func (i *Interactive) redraw() { | |||||
| 2104 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | 2121 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | ||
| 2105 | cursorRow = dialogLead + r | 2122 | cursorRow = dialogLead + r | ||
| 2106 | cursorCol = c | 2123 | cursorCol = c | ||
| 2124 | + | cursorInDialog = true | |||
| 2107 | } | 2125 | } | ||
| 2108 | } | 2126 | } | ||
| 2109 | if i.sessionTreeDialog.Active() { | 2127 | if i.sessionTreeDialog.Active() { | ||
| 2110 | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | 2128 | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | ||
| 2111 | cursorRow = dialogLead + r | 2129 | cursorRow = dialogLead + r | ||
| 2112 | cursorCol = c | 2130 | cursorCol = c | ||
| 2131 | + | cursorInDialog = true | |||
| 2113 | } | 2132 | } | ||
| 2114 | } | 2133 | } | ||
| 2115 | if i.subagentsDialog.Active() { | 2134 | if i.subagentsDialog.Active() { | ||
| 2116 | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { | 2135 | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { | ||
| 2117 | cursorRow = dialogLead + r | 2136 | cursorRow = dialogLead + r | ||
| 2118 | cursorCol = c | 2137 | cursorCol = c | ||
| 2138 | + | cursorInDialog = true | |||
| 2119 | } else { | 2139 | } else { | ||
| 2120 | // Dashboard list / transcript view has no caret. Without | 2140 | // Dashboard list / transcript view has no caret. Without | ||
| 2121 | // this branch the default cursorRow points at the | 2141 | // this branch the default cursorRow points at the | ||
| @@ -2129,9 +2149,14 @@ func (i *Interactive) redraw() { | |||||
| 2129 | cursorRow = -1 | 2149 | cursorRow = -1 | ||
| 2130 | cursorCol = 0 | 2150 | cursorCol = 0 | ||
| 2131 | } | 2151 | } | ||
| 2152 | + | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |||
| 2132 | if rightBarActive { | 2153 | if rightBarActive { | ||
| 2133 | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | 2154 | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | ||
| 2134 | - | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | 2155 | + | if modalBackdrop { |
| 2156 | + | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | |||
| 2157 | + | } else { | |||
| 2158 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 2159 | + | } | |||
| 2135 | } else { | 2160 | } else { | ||
| 2136 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 2161 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | ||
| 2137 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 2162 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | ||
| @@ -3951,11 +3976,28 @@ func onOff(v bool) string { | |||||
| 3951 | return "disabled" | 3976 | return "disabled" | ||
| 3952 | } | 3977 | } | ||
| 3953 | 3978 | ||||
| 3979 | + | const modalBackdropDimPercent = 50 | |||
| 3980 | + | ||||
| 3954 | func (i *Interactive) applyInputCursorColor() { | 3981 | func (i *Interactive) applyInputCursorColor() { | ||
| 3955 | if i == nil || i.cfg.Terminal == nil { | 3982 | if i == nil || i.cfg.Terminal == nil { | ||
| 3956 | return | 3983 | return | ||
| 3957 | } | 3984 | } | ||
| 3958 | - | _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock())) | 3985 | + | color := tui.Color256(15) |
| 3986 | + | if i.cursorDimmed { | |||
| 3987 | + | color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent) | |||
| 3988 | + | } | |||
| 3989 | + | _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock())) | |||
| 3990 | + | } | |||
| 3991 | + | ||||
| 3992 | + | // setInputCursorDimmed updates the terminal-owned cursor only when its layer | |||
| 3993 | + | // changes. Reapplying cursor controls on every redraw can reset its blink | |||
| 3994 | + | // timing before the terminal completes a normal blink cycle. | |||
| 3995 | + | func (i *Interactive) setInputCursorDimmed(dimmed bool) { | |||
| 3996 | + | if i == nil || i.cursorDimmed == dimmed { | |||
| 3997 | + | return | |||
| 3998 | + | } | |||
| 3999 | + | i.cursorDimmed = dimmed | |||
| 4000 | + | i.applyInputCursorColor() | |||
| 3959 | } | 4001 | } | ||
| 3960 | 4002 | ||||
| 3961 | func reasoningSettingOptions(levels []string) []settingsOption { | 4003 | func reasoningSettingOptions(levels []string) []settingsOption { | ||
A packages/agent/modes/modal_backdrop_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |
| 11 | + | term := &alertTestTerminal{} | |
| 12 | + | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |
| 13 | + | i.rend.Resize(80, 24) | |
| 14 | + | i.ed.SetValue("draft") | |
| 15 | + | if !i.settingsDialog.Open([]settingsItem{{key: "test", label: "test setting"}}) { | |
| 16 | + | t.Fatal("settings dialog did not open") | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | i.redraw() | |
| 20 | + | got := term.String() | |
| 21 | + | ||
| 22 | + | input := i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) + "draft" | |
| 23 | + | if !strings.Contains(got, tui.Dim(input)) { | |
| 24 | + | t.Fatalf("settings dialog left the main input undimmed: %q", got) | |
| 25 | + | } | |
| 26 | + | if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) { | |
| 27 | + | t.Fatalf("settings dialog itself was dimmed: %q", got) | |
| 28 | + | } | |
| 29 | + | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | |
| 30 | + | if !strings.Contains(got, cursor) { | |
| 31 | + | t.Fatalf("settings dialog left the main cursor bright; missing %q in %q", cursor, got) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | term.mu.Lock() | |
| 35 | + | term.data = nil | |
| 36 | + | term.mu.Unlock() | |
| 37 | + | i.settingsDialog.Close() | |
| 38 | + | i.redraw() | |
| 39 | + | if got := term.String(); !strings.Contains(got, tui.CursorColor(tui.Color256(15))) { | |
| 40 | + | t.Fatalf("closing settings did not restore the main cursor color: %q", got) | |
| 41 | + | } | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |||
| 11 | + | term := &alertTestTerminal{} | |||
| 12 | + | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |||
| 13 | + | i.rend.Resize(80, 24) | |||
| 14 | + | i.ed.SetValue("draft") | |||
| 15 | + | if !i.settingsDialog.Open([]settingsItem{{key: "test", label: "test setting"}}) { | |||
| 16 | + | t.Fatal("settings dialog did not open") | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | i.redraw() | |||
| 20 | + | got := term.String() | |||
| 21 | + | ||||
| 22 | + | input := i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) + "draft" | |||
| 23 | + | if !strings.Contains(got, tui.Dim(input)) { | |||
| 24 | + | t.Fatalf("settings dialog left the main input undimmed: %q", got) | |||
| 25 | + | } | |||
| 26 | + | if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) { | |||
| 27 | + | t.Fatalf("settings dialog itself was dimmed: %q", got) | |||
| 28 | + | } | |||
| 29 | + | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | |||
| 30 | + | if !strings.Contains(got, cursor) { | |||
| 31 | + | t.Fatalf("settings dialog left the main cursor bright; missing %q in %q", cursor, got) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | term.mu.Lock() | |||
| 35 | + | term.data = nil | |||
| 36 | + | term.mu.Unlock() | |||
| 37 | + | i.settingsDialog.Close() | |||
| 38 | + | i.redraw() | |||
| 39 | + | if got := term.String(); !strings.Contains(got, tui.CursorColor(tui.Color256(15))) { | |||
| 40 | + | t.Fatalf("closing settings did not restore the main cursor color: %q", got) | |||
| 41 | + | } | |||
| 42 | + | } | |||
M packages/agent/modes/session_title_test.go +3 -2
| @@ -239,11 +239,12 @@ func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) { | |||
| 239 | 239 | ||
| 240 | 240 | i.Submit("new branch work") | |
| 241 | 241 | waitInteractiveIdle(t, i) | |
| 242 | - | ||
| 242 | + | // The hidden title request is deliberately asynchronous, so wait for its | |
| 243 | + | // visible result rather than assuming it finishes before the main turn. | |
| 244 | + | waitInteractiveTitle(t, i, "new branch title") | |
| 243 | 245 | if got := client.requestCount(); got != 2 { | |
| 244 | 246 | t.Fatalf("provider request count = %d, want main + hidden title", got) | |
| 245 | 247 | } | |
| 246 | - | waitInteractiveTitle(t, i, "new branch title") | |
| 247 | 248 | i.mu.Lock() | |
| 248 | 249 | seen := i.titleRealPromptSeen | |
| 249 | 250 | i.mu.Unlock() | |
| @@ -239,11 +239,12 @@ func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) { | |||||
| 239 | 239 | ||||
| 240 | i.Submit("new branch work") | 240 | i.Submit("new branch work") | ||
| 241 | waitInteractiveIdle(t, i) | 241 | waitInteractiveIdle(t, i) | ||
| 242 | - | 242 | + | // The hidden title request is deliberately asynchronous, so wait for its | |
| 243 | + | // visible result rather than assuming it finishes before the main turn. | |||
| 244 | + | waitInteractiveTitle(t, i, "new branch title") | |||
| 243 | if got := client.requestCount(); got != 2 { | 245 | if got := client.requestCount(); got != 2 { | ||
| 244 | t.Fatalf("provider request count = %d, want main + hidden title", got) | 246 | t.Fatalf("provider request count = %d, want main + hidden title", got) | ||
| 245 | } | 247 | } | ||
| 246 | - | waitInteractiveTitle(t, i, "new branch title") | |||
| 247 | i.mu.Lock() | 248 | i.mu.Lock() | ||
| 248 | seen := i.titleRealPromptSeen | 249 | seen := i.titleRealPromptSeen | ||
| 249 | i.mu.Unlock() | 250 | i.mu.Unlock() | ||
A packages/tui/layer_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) { | |
| 9 | + | lines := []string{ | |
| 10 | + | "plain", | |
| 11 | + | Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"), | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | got := DimLines(lines) | |
| 15 | + | want := []string{ | |
| 16 | + | "\x1b[2mplain\x1b[22m", | |
| 17 | + | "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[22m", | |
| 18 | + | } | |
| 19 | + | if !reflect.DeepEqual(got, want) { | |
| 20 | + | t.Fatalf("DimLines() = %#v, want %#v", got, want) | |
| 21 | + | } | |
| 22 | + | if !reflect.DeepEqual(lines, []string{ | |
| 23 | + | "plain", | |
| 24 | + | Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"), | |
| 25 | + | }) { | |
| 26 | + | t.Fatalf("DimLines mutated input: %#v", lines) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestDimRetainsFaintAfterBold(t *testing.T) { | |
| 31 | + | const ( | |
| 32 | + | dim = "\x1b[2m" | |
| 33 | + | off = "\x1b[22m" | |
| 34 | + | ) | |
| 35 | + | if got, want := Dim("before "+Bold("bold")+" after"), dim+"before \x1b[1mbold"+off+dim+" after"+off; got != want { | |
| 36 | + | t.Fatalf("Dim() = %q, want %q", got, want) | |
| 37 | + | } | |
| 38 | + | if got, want := "\x1b[31m"+Dim("faint")+" tail", "\x1b[31m"+dim+"faint"+off+" tail"; got != want { | |
| 39 | + | t.Fatalf("Dim() reset its caller's color: %q, want %q", got, want) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestCursorColorUsesTerminalColor(t *testing.T) { | |
| 44 | + | if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want { | |
| 45 | + | t.Fatalf("CursorColor() = %q, want %q", got, want) | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) { | |||
| 9 | + | lines := []string{ | |||
| 10 | + | "plain", | |||
| 11 | + | Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"), | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | got := DimLines(lines) | |||
| 15 | + | want := []string{ | |||
| 16 | + | "\x1b[2mplain\x1b[22m", | |||
| 17 | + | "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[22m", | |||
| 18 | + | } | |||
| 19 | + | if !reflect.DeepEqual(got, want) { | |||
| 20 | + | t.Fatalf("DimLines() = %#v, want %#v", got, want) | |||
| 21 | + | } | |||
| 22 | + | if !reflect.DeepEqual(lines, []string{ | |||
| 23 | + | "plain", | |||
| 24 | + | Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"), | |||
| 25 | + | }) { | |||
| 26 | + | t.Fatalf("DimLines mutated input: %#v", lines) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestDimRetainsFaintAfterBold(t *testing.T) { | |||
| 31 | + | const ( | |||
| 32 | + | dim = "\x1b[2m" | |||
| 33 | + | off = "\x1b[22m" | |||
| 34 | + | ) | |||
| 35 | + | if got, want := Dim("before "+Bold("bold")+" after"), dim+"before \x1b[1mbold"+off+dim+" after"+off; got != want { | |||
| 36 | + | t.Fatalf("Dim() = %q, want %q", got, want) | |||
| 37 | + | } | |||
| 38 | + | if got, want := "\x1b[31m"+Dim("faint")+" tail", "\x1b[31m"+dim+"faint"+off+" tail"; got != want { | |||
| 39 | + | t.Fatalf("Dim() reset its caller's color: %q, want %q", got, want) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestCursorColorUsesTerminalColor(t *testing.T) { | |||
| 44 | + | if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want { | |||
| 45 | + | t.Fatalf("CursorColor() = %q, want %q", got, want) | |||
| 46 | + | } | |||
| 47 | + | } | |||
M packages/tui/layout.go +11 -3
| @@ -82,11 +82,19 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||
| 82 | 82 | return out | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | - | func CursorColor256(index int) string { | |
| 86 | - | r, g, b := xterm256RGB(index) | |
| 87 | - | return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07" | |
| 85 | + | // CursorColor returns OSC 12 to set the terminal cursor color. Unlike SGR | |
| 86 | + | // styling, the cursor is terminal-owned, so modal backdrops must set this | |
| 87 | + | // separately from their dimmed text rows. | |
| 88 | + | func CursorColor(color TerminalColor) string { | |
| 89 | + | rgb, ok := rgbForTerminalColor(color) | |
| 90 | + | if !ok { | |
| 91 | + | return "" | |
| 92 | + | } | |
| 93 | + | return "\x1b]12;rgb:" + hexByte(rgb[0]) + "/" + hexByte(rgb[1]) + "/" + hexByte(rgb[2]) + "\x07" | |
| 88 | 94 | } | |
| 89 | 95 | ||
| 96 | + | func CursorColor256(index int) string { return CursorColor(Color256(index)) } | |
| 97 | + | ||
| 90 | 98 | func ResetCursorColor() string { return "\x1b]112\x07" } | |
| 91 | 99 | ||
| 92 | 100 | func CursorShapeBlock() string { return "\x1b[1 q" } | |
| @@ -82,11 +82,19 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||||
| 82 | return out | 82 | return out | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | - | func CursorColor256(index int) string { | 85 | + | // CursorColor returns OSC 12 to set the terminal cursor color. Unlike SGR |
| 86 | - | r, g, b := xterm256RGB(index) | 86 | + | // styling, the cursor is terminal-owned, so modal backdrops must set this |
| 87 | - | return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07" | 87 | + | // separately from their dimmed text rows. |
| 88 | + | func CursorColor(color TerminalColor) string { | |||
| 89 | + | rgb, ok := rgbForTerminalColor(color) | |||
| 90 | + | if !ok { | |||
| 91 | + | return "" | |||
| 92 | + | } | |||
| 93 | + | return "\x1b]12;rgb:" + hexByte(rgb[0]) + "/" + hexByte(rgb[1]) + "/" + hexByte(rgb[2]) + "\x07" | |||
| 88 | } | 94 | } | ||
| 89 | 95 | ||||
| 96 | + | func CursorColor256(index int) string { return CursorColor(Color256(index)) } | |||
| 97 | + | ||||
| 90 | func ResetCursorColor() string { return "\x1b]112\x07" } | 98 | func ResetCursorColor() string { return "\x1b]112\x07" } | ||
| 91 | 99 | ||||
| 92 | func CursorShapeBlock() string { return "\x1b[1 q" } | 100 | func CursorShapeBlock() string { return "\x1b[1 q" } | ||
M packages/tui/rightbar.go +20 -2
| @@ -347,6 +347,10 @@ func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *b | |||
| 347 | 347 | // allowing either side to soft-wrap. The returned row occupies exactly | |
| 348 | 348 | // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells. | |
| 349 | 349 | func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string { | |
| 350 | + | return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string { | |
| 350 | 354 | if mainWidth < 1 { | |
| 351 | 355 | mainWidth = 1 | |
| 352 | 356 | } | |
| @@ -361,7 +365,11 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||
| 361 | 365 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | |
| 362 | 366 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | |
| 363 | 367 | } | |
| 364 | - | return main + th.FGColor(th.Muted, "│") + rightBar | |
| 368 | + | separator := th.FGColor(th.Muted, "│") | |
| 369 | + | if dimSeparator { | |
| 370 | + | separator = Dim(separator) | |
| 371 | + | } | |
| 372 | + | return main + separator + rightBar | |
| 365 | 373 | } | |
| 366 | 374 | ||
| 367 | 375 | // DrawRightBar renders an already-laid-out visible chat slice and sticky | |
| @@ -371,6 +379,16 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||
| 371 | 379 | // stale rows behind. Interactive mode still owns chat scrolling and passes | |
| 372 | 380 | // the selected visible slice here. | |
| 373 | 381 | func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | |
| 382 | + | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // DrawRightBarDimmed renders a right-bar frame whose separator is part of a | |
| 386 | + | // dimmed backdrop. Callers must pass already-dimmed main and right-bar rows. | |
| 387 | + | func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | |
| 388 | + | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true) | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) { | |
| 374 | 392 | if r.cols == 0 || r.rows == 0 { | |
| 375 | 393 | return | |
| 376 | 394 | } | |
| @@ -407,7 +425,7 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||
| 407 | 425 | ||
| 408 | 426 | frame := make([]string, r.rows) | |
| 409 | 427 | for row := range frame { | |
| 410 | - | frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth) | |
| 428 | + | frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator) | |
| 411 | 429 | } | |
| 412 | 430 | ||
| 413 | 431 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | |
| @@ -347,6 +347,10 @@ func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *b | |||||
| 347 | // allowing either side to soft-wrap. The returned row occupies exactly | 347 | // allowing either side to soft-wrap. The returned row occupies exactly | ||
| 348 | // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells. | 348 | // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells. | ||
| 349 | func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string { | 349 | func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string { | ||
| 350 | + | return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false) | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string { | |||
| 350 | if mainWidth < 1 { | 354 | if mainWidth < 1 { | ||
| 351 | mainWidth = 1 | 355 | mainWidth = 1 | ||
| 352 | } | 356 | } | ||
| @@ -361,7 +365,11 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||||
| 361 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | 365 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | ||
| 362 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | 366 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | ||
| 363 | } | 367 | } | ||
| 364 | - | return main + th.FGColor(th.Muted, "│") + rightBar | 368 | + | separator := th.FGColor(th.Muted, "│") |
| 369 | + | if dimSeparator { | |||
| 370 | + | separator = Dim(separator) | |||
| 371 | + | } | |||
| 372 | + | return main + separator + rightBar | |||
| 365 | } | 373 | } | ||
| 366 | 374 | ||||
| 367 | // DrawRightBar renders an already-laid-out visible chat slice and sticky | 375 | // DrawRightBar renders an already-laid-out visible chat slice and sticky | ||
| @@ -371,6 +379,16 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||||
| 371 | // stale rows behind. Interactive mode still owns chat scrolling and passes | 379 | // stale rows behind. Interactive mode still owns chat scrolling and passes | ||
| 372 | // the selected visible slice here. | 380 | // the selected visible slice here. | ||
| 373 | func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | 381 | func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | ||
| 382 | + | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false) | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // DrawRightBarDimmed renders a right-bar frame whose separator is part of a | |||
| 386 | + | // dimmed backdrop. Callers must pass already-dimmed main and right-bar rows. | |||
| 387 | + | func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | |||
| 388 | + | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true) | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) { | |||
| 374 | if r.cols == 0 || r.rows == 0 { | 392 | if r.cols == 0 || r.rows == 0 { | ||
| 375 | return | 393 | return | ||
| 376 | } | 394 | } | ||
| @@ -407,7 +425,7 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||||
| 407 | 425 | ||||
| 408 | frame := make([]string, r.rows) | 426 | frame := make([]string, r.rows) | ||
| 409 | for row := range frame { | 427 | for row := range frame { | ||
| 410 | - | frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth) | 428 | + | frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator) |
| 411 | } | 429 | } | ||
| 412 | 430 | ||||
| 413 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | 431 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | ||
M packages/tui/rightbar_test.go +20 -0
| @@ -284,3 +284,23 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | |||
| 284 | 284 | t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail) | |
| 285 | 285 | } | |
| 286 | 286 | } | |
| 287 | + | ||
| 288 | + | func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) { | |
| 289 | + | var out bytes.Buffer | |
| 290 | + | r := NewRenderer(&out) | |
| 291 | + | r.SetTheme(Dark) | |
| 292 | + | r.Resize(80, 5) | |
| 293 | + | ||
| 294 | + | r.DrawRightBarDimmed( | |
| 295 | + | DimLines([]string{"chat"}), | |
| 296 | + | DimLines([]string{"input"}), | |
| 297 | + | DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)), | |
| 298 | + | 0, | |
| 299 | + | 0, | |
| 300 | + | ) | |
| 301 | + | ||
| 302 | + | separator := Dim(Dark.FGColor(Dark.Muted, "│")) | |
| 303 | + | if !strings.Contains(out.String(), separator) { | |
| 304 | + | t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String()) | |
| 305 | + | } | |
| 306 | + | } | |
| @@ -284,3 +284,23 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | |||||
| 284 | t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail) | 284 | t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail) | ||
| 285 | } | 285 | } | ||
| 286 | } | 286 | } | ||
| 287 | + | ||||
| 288 | + | func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) { | |||
| 289 | + | var out bytes.Buffer | |||
| 290 | + | r := NewRenderer(&out) | |||
| 291 | + | r.SetTheme(Dark) | |||
| 292 | + | r.Resize(80, 5) | |||
| 293 | + | ||||
| 294 | + | r.DrawRightBarDimmed( | |||
| 295 | + | DimLines([]string{"chat"}), | |||
| 296 | + | DimLines([]string{"input"}), | |||
| 297 | + | DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)), | |||
| 298 | + | 0, | |||
| 299 | + | 0, | |||
| 300 | + | ) | |||
| 301 | + | ||||
| 302 | + | separator := Dim(Dark.FGColor(Dark.Muted, "│")) | |||
| 303 | + | if !strings.Contains(out.String(), separator) { | |||
| 304 | + | t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String()) | |||
| 305 | + | } | |||
| 306 | + | } | |||
M packages/tui/theme.go +30 -6
| @@ -405,17 +405,41 @@ func (t Theme) UserBubbleRow(content string, width int) string { | |||
| 405 | 405 | return bar + t.UserBubble(content, bubbleW) | |
| 406 | 406 | } | |
| 407 | 407 | ||
| 408 | - | // Bold wraps s in bold SGR. | |
| 409 | - | func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" } | |
| 408 | + | const ( | |
| 409 | + | reset = "\x1b[0m" | |
| 410 | + | dim = "\x1b[2m" | |
| 411 | + | normalIntensity = "\x1b[22m" | |
| 412 | + | ) | |
| 410 | 413 | ||
| 411 | - | // Dim wraps s in dim SGR. | |
| 412 | - | func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" } | |
| 414 | + | var dimStyleReplacer = strings.NewReplacer( | |
| 415 | + | reset, reset+dim, | |
| 416 | + | normalIntensity, normalIntensity+dim, | |
| 417 | + | ) | |
| 418 | + | ||
| 419 | + | // Bold wraps s in bold SGR. | |
| 420 | + | func Bold(s string) string { return "\x1b[1m" + s + normalIntensity } | |
| 421 | + | ||
| 422 | + | // Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so | |
| 423 | + | // independently styled segments remain dimmed too. Like Bold and Italic, it | |
| 424 | + | // restores only its own attribute so callers can safely compose styles. | |
| 425 | + | func Dim(s string) string { | |
| 426 | + | return dim + dimStyleReplacer.Replace(s) + normalIntensity | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | // DimLines returns a dimmed copy of lines. It is suitable for content behind | |
| 430 | + | // a modal layer: the foreground layer can retain its normal styling while | |
| 431 | + | // every styled segment in the background stays dimmed. | |
| 432 | + | func DimLines(lines []string) []string { | |
| 433 | + | out := make([]string, len(lines)) | |
| 434 | + | for idx, line := range lines { | |
| 435 | + | out[idx] = Dim(line) | |
| 436 | + | } | |
| 437 | + | return out | |
| 438 | + | } | |
| 413 | 439 | ||
| 414 | 440 | // Italic wraps s in italic SGR. | |
| 415 | 441 | func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" } | |
| 416 | 442 | ||
| 417 | - | const reset = "\x1b[0m" | |
| 418 | - | ||
| 419 | 443 | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" } | |
| 420 | 444 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" } | |
| 421 | 445 | func sgrBGColor(c TerminalColor) string { | |
| @@ -405,17 +405,41 @@ func (t Theme) UserBubbleRow(content string, width int) string { | |||||
| 405 | return bar + t.UserBubble(content, bubbleW) | 405 | return bar + t.UserBubble(content, bubbleW) | ||
| 406 | } | 406 | } | ||
| 407 | 407 | ||||
| 408 | - | // Bold wraps s in bold SGR. | 408 | + | const ( |
| 409 | - | func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" } | 409 | + | reset = "\x1b[0m" |
| 410 | + | dim = "\x1b[2m" | |||
| 411 | + | normalIntensity = "\x1b[22m" | |||
| 412 | + | ) | |||
| 410 | 413 | ||||
| 411 | - | // Dim wraps s in dim SGR. | 414 | + | var dimStyleReplacer = strings.NewReplacer( |
| 412 | - | func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" } | 415 | + | reset, reset+dim, |
| 416 | + | normalIntensity, normalIntensity+dim, | |||
| 417 | + | ) | |||
| 418 | + | ||||
| 419 | + | // Bold wraps s in bold SGR. | |||
| 420 | + | func Bold(s string) string { return "\x1b[1m" + s + normalIntensity } | |||
| 421 | + | ||||
| 422 | + | // Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so | |||
| 423 | + | // independently styled segments remain dimmed too. Like Bold and Italic, it | |||
| 424 | + | // restores only its own attribute so callers can safely compose styles. | |||
| 425 | + | func Dim(s string) string { | |||
| 426 | + | return dim + dimStyleReplacer.Replace(s) + normalIntensity | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // DimLines returns a dimmed copy of lines. It is suitable for content behind | |||
| 430 | + | // a modal layer: the foreground layer can retain its normal styling while | |||
| 431 | + | // every styled segment in the background stays dimmed. | |||
| 432 | + | func DimLines(lines []string) []string { | |||
| 433 | + | out := make([]string, len(lines)) | |||
| 434 | + | for idx, line := range lines { | |||
| 435 | + | out[idx] = Dim(line) | |||
| 436 | + | } | |||
| 437 | + | return out | |||
| 438 | + | } | |||
| 413 | 439 | ||||
| 414 | // Italic wraps s in italic SGR. | 440 | // Italic wraps s in italic SGR. | ||
| 415 | func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" } | 441 | func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" } | ||
| 416 | 442 | ||||
| 417 | - | const reset = "\x1b[0m" | |||
| 418 | - | ||||
| 419 | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" } | 443 | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" } | ||
| 420 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" } | 444 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" } | ||
| 421 | func sgrBGColor(c TerminalColor) string { | 445 | func sgrBGColor(c TerminalColor) string { | ||