Skip to content

Commit 706dd85

Browse files
committed
refine visual selection styling
1 parent aee5d35 commit 706dd85

4 files changed

Lines changed: 107 additions & 45 deletions

File tree

‎PLAN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ The TUI stability and modal polish milestone is implemented:
247247
- Emoji rendering is configurable through `emoji_mode`; `auto` preserves full emoji on capable UTF-8 terminals and falls back to compatibility rendering for terminals such as `st`.
248248
- The status bar has a single authoritative mode indicator, keeps pywal colors by default, and supports per-mode hex overrides.
249249
- `/` search shows match counts in the status bar and `Esc` clears active search state without requiring a blank search.
250-
- The current chat/message cursor items use stronger terminal-safe border/shadow treatments, while visual-mode message ranges use a high-contrast fill derived from the configurable visual indicator color plus distinct endpoint cursor borders so every selected bubble is immediately recognizable.
250+
- The current chat/message cursor items use stronger terminal-safe border treatments, while visual-mode message ranges use a dark theme-blended tint, a full-strength configurable accent rail, readable foreground contrast, and a distinct thick endpoint cursor so every selected bubble is immediately recognizable without looking like an opaque form field.
251251
- Normal mode can yank the focused message body directly, while visual mode continues to yank selected message ranges through the same register/clipboard path.
252252
- Active-chat refreshes auto-follow newly appended messages only when the cursor was already on the previous latest message; otherwise the viewport stays anchored, a persistent `Mensagens novas: X` divider is inserted into the chat flow until the user reaches the tail, and the footer/composer shows a compact down-arrow count for messages still below the viewport.
253253
- Unread chat counters render as compact highlighted badges capped at `99+`, while thick borders remain reserved for cursor/focus state.

‎internal/ui/model_test.go‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4487,8 +4487,8 @@ func TestActiveMessageBubbleUsesStrongerCursorBorder(t *testing.T) {
44874487
}
44884488

44894489
selectedBubble := stripANSI(model.renderMessageBubbleForViewport(message, 70, false, true, nil))
4490-
if !strings.Contains(selectedBubble, "┏") || !strings.Contains(selectedBubble, "┗") {
4491-
t.Fatalf("selected message bubble did not use thick visual border\n%s", selectedBubble)
4490+
if !strings.Contains(selectedBubble, "╭") || !strings.Contains(selectedBubble, "╰") || !strings.Contains(selectedBubble, "┃") || strings.Contains(selectedBubble, "┏") {
4491+
t.Fatalf("selected message bubble did not keep a clean rounded frame\n%s", selectedBubble)
44924492
}
44934493

44944494
activeSelectedBubble := stripANSI(model.renderMessageBubbleForViewport(message, 70, true, true, nil))
@@ -4520,10 +4520,14 @@ func TestSelectedMessageBubbleUsesHighContrastFill(t *testing.T) {
45204520
if !hasSGRCode(selectedCodes, "38") || !hasSGRCode(selectedCodes, "48") {
45214521
t.Fatalf("selected message body codes = %v, want foreground and background fill", selectedCodes)
45224522
}
4523-
background, foreground := model.visualSelectionColors()
4524-
wantForeground := contrastingTextColor(background, uiTheme.BarBG, primaryFG)
4525-
if background != lipgloss.Color("#F4D35E") || foreground != wantForeground {
4526-
t.Fatalf("selection colors = %q/%q, want #F4D35E/%q", background, foreground, wantForeground)
4523+
palette := model.visualSelectionPalette()
4524+
wantBackground := blendColors(uiTheme.BarBG, lipgloss.Color("#F4D35E"), visualSelectionTint)
4525+
wantForeground := contrastingTextColor(wantBackground, uiTheme.BarBG, primaryFG)
4526+
if palette.Accent != lipgloss.Color("#F4D35E") || palette.Background != wantBackground || palette.Foreground != wantForeground {
4527+
t.Fatalf("selection palette = %+v, want accent #F4D35E background %q foreground %q", palette, wantBackground, wantForeground)
4528+
}
4529+
if palette.Background == palette.Accent {
4530+
t.Fatalf("selection background = raw accent %q, want a quieter tint", palette.Background)
45274531
}
45284532

45294533
activeSelected := model.renderMessageBubbleForViewport(message, 70, true, true, nil)
@@ -5576,7 +5580,7 @@ func TestVisualFooterIsMinimal(t *testing.T) {
55765580
}
55775581
}
55785582

5579-
func TestVisualSelectionRangeUsesStrongerBorders(t *testing.T) {
5583+
func TestVisualSelectionRangeUsesTintedFillAndEndpointBorder(t *testing.T) {
55805584
withANSIStyles(t)
55815585
model := NewModel(Options{
55825586
Snapshot: store.Snapshot{
@@ -5600,11 +5604,11 @@ func TestVisualSelectionRangeUsesStrongerBorders(t *testing.T) {
56005604

56015605
rendered := model.renderMessages(70, 14)
56025606
view := stripANSI(rendered)
5603-
if got := strings.Count(view, "┏"); got != 3 {
5604-
t.Fatalf("visual selected thick border count = %d, want 3\n%s", got, view)
5607+
if got := strings.Count(view, "┏"); got != 1 {
5608+
t.Fatalf("visual selected thick endpoint count = %d, want 1\n%s", got, view)
56055609
}
5606-
if strings.Contains(view, "╭") || strings.Contains(view, "╰") {
5607-
t.Fatalf("visual selected range kept rounded borders\n%s", view)
5610+
if got := strings.Count(view, "╭"); got != 2 {
5611+
t.Fatalf("visual selected rounded range count = %d, want 2\n%s", got, view)
56085612
}
56095613
for _, body := range []string{"first", "second", "third"} {
56105614
if codes := sgrCodesBeforeNth(rendered, body, 0); !hasSGRCode(codes, "48") {
@@ -10790,6 +10794,9 @@ func TestContrastingTextColorChoosesReadableThemeColor(t *testing.T) {
1079010794
if got := contrastingTextColor(lipgloss.Color("#203040"), dark, light); got != light {
1079110795
t.Fatalf("dark selection foreground = %q, want %q", got, light)
1079210796
}
10797+
if got := blendColors(lipgloss.Color("#101418"), lipgloss.Color("#F4D35E"), 0.28); got != lipgloss.Color("#50492C") {
10798+
t.Fatalf("selection tint = %q, want #50492C", got)
10799+
}
1079310800
}
1079410801

1079510802
func TestFullViewDoesNotGrowPastTerminalHeightWithManyMessages(t *testing.T) {

‎internal/ui/theme.go‎

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ui
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"math"
67
"os"
78
"path/filepath"
@@ -133,28 +134,65 @@ func contrastingTextColor(background, first, second lipgloss.Color) lipgloss.Col
133134
}
134135

135136
func colorLuminance(color lipgloss.Color) (float64, bool) {
137+
rgb, ok := parseHexColor(color)
138+
if !ok {
139+
return 0, false
140+
}
141+
component := func(channel uint8) float64 {
142+
value := float64(channel) / 255
143+
if value <= 0.04045 {
144+
return value / 12.92
145+
}
146+
return math.Pow((value+0.055)/1.055, 2.4)
147+
}
148+
red := component(rgb.red)
149+
green := component(rgb.green)
150+
blue := component(rgb.blue)
151+
return 0.2126*red + 0.7152*green + 0.0722*blue, true
152+
}
153+
154+
type themeRGB struct {
155+
red uint8
156+
green uint8
157+
blue uint8
158+
}
159+
160+
func parseHexColor(color lipgloss.Color) (themeRGB, bool) {
136161
value := strings.TrimPrefix(strings.TrimSpace(string(color)), "#")
137162
if len(value) == 3 {
138163
value = strings.Repeat(value[0:1], 2) + strings.Repeat(value[1:2], 2) + strings.Repeat(value[2:3], 2)
139164
}
140165
if len(value) != 6 {
141-
return 0, false
166+
return themeRGB{}, false
142167
}
143168
rgb, err := strconv.ParseUint(value, 16, 32)
144169
if err != nil {
145-
return 0, false
170+
return themeRGB{}, false
146171
}
147-
component := func(channel uint64) float64 {
148-
value := float64(channel) / 255
149-
if value <= 0.04045 {
150-
return value / 12.92
151-
}
152-
return math.Pow((value+0.055)/1.055, 2.4)
172+
return themeRGB{
173+
red: uint8((rgb >> 16) & 0xff),
174+
green: uint8((rgb >> 8) & 0xff),
175+
blue: uint8(rgb & 0xff),
176+
}, true
177+
}
178+
179+
func blendColors(base, accent lipgloss.Color, accentWeight float64) lipgloss.Color {
180+
baseRGB, baseOK := parseHexColor(base)
181+
accentRGB, accentOK := parseHexColor(accent)
182+
if !baseOK || !accentOK {
183+
return accent
153184
}
154-
red := component((rgb >> 16) & 0xff)
155-
green := component((rgb >> 8) & 0xff)
156-
blue := component(rgb & 0xff)
157-
return 0.2126*red + 0.7152*green + 0.0722*blue, true
185+
accentWeight = min(1, max(0, accentWeight))
186+
baseWeight := 1 - accentWeight
187+
blend := func(base, accent uint8) uint8 {
188+
return uint8(math.Round(float64(base)*baseWeight + float64(accent)*accentWeight))
189+
}
190+
return lipgloss.Color(fmt.Sprintf(
191+
"#%02X%02X%02X",
192+
blend(baseRGB.red, accentRGB.red),
193+
blend(baseRGB.green, accentRGB.green),
194+
blend(baseRGB.blue, accentRGB.blue),
195+
))
158196
}
159197

160198
func contrastRatio(first, second float64) float64 {

‎internal/ui/view.go‎

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const (
3838
chatCellHeight = 4
3939
lastColumnGuardMargin = 2
4040
maxComposerHeight = 12
41+
visualSelectionTint = 0.28
4142
)
4243

4344
func (m Model) View() string {
@@ -1612,15 +1613,15 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16121613
border := incomingLine
16131614
fg := primaryFG
16141615
metaFG := softFG
1615-
selectionBG, selectionFG := m.visualSelectionColors()
1616+
selection := m.visualSelectionPalette()
16161617
if message.IsOutgoing {
16171618
border = outgoingLine
16181619
fg = outgoingFG
16191620
}
16201621
if selected {
1621-
border = selectionBG
1622-
fg = selectionFG
1623-
metaFG = selectionFG
1622+
border = selection.Accent
1623+
fg = selection.Foreground
1624+
metaFG = selection.Foreground
16241625
}
16251626
if active {
16261627
border = focusedLine
@@ -1648,7 +1649,7 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16481649
Border(messageBubbleBorder(active, selected)).
16491650
Padding(0, 1)
16501651
if selected {
1651-
boxStyle = boxStyle.Background(selectionBG)
1652+
boxStyle = boxStyle.Background(selection.Background)
16521653
}
16531654
boxStyle = highlightedItemBorderStyle(boxStyle, border, active, selected)
16541655
maxBubbleWidth := max(6, bubbleWidth(availableWidth))
@@ -1667,17 +1668,17 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16671668
if deleted {
16681669
bodyStyle = lipgloss.NewStyle().Foreground(softFG).Italic(true)
16691670
}
1670-
bodyStyle = visualSelectionContentStyle(bodyStyle, selected, selectionBG, selectionFG)
1671+
bodyStyle = visualSelectionContentStyle(bodyStyle, selected, selection)
16711672
messageSearchQuery := m.messageSearchQuery()
16721673

16731674
var lines []string
16741675
if sender != "" {
1675-
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG).Bold(true), selected, selectionBG, selectionFG)
1676+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG).Bold(true), selected, selection)
16761677
lines = append(lines, style.Render(truncateDisplay(sender, contentWidth)))
16771678
}
16781679
if !deleted {
16791680
if quote := m.messageQuoteLine(message, contentWidth); quote != "" {
1680-
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG).Italic(true), selected, selectionBG, selectionFG)
1681+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG).Italic(true), selected, selection)
16811682
lines = append(lines, style.Render(quote))
16821683
}
16831684
}
@@ -1699,42 +1700,59 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16991700
}
17001701
if !deleted {
17011702
if reactions := m.messageReactionLine(message, contentWidth); reactions != "" {
1702-
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG), selected, selectionBG, selectionFG)
1703+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG), selected, selection)
17031704
lines = append(lines, style.Render(reactions))
17041705
}
17051706
}
17061707
if meta != "" {
17071708
meta = truncateDisplay(meta, contentWidth)
1708-
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG), selected, selectionBG, selectionFG)
1709+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG), selected, selection)
17091710
lines = append(lines, style.Render(alignDisplay(meta, contentWidth, true)))
17101711
}
17111712

17121713
return boxStyle.Width(bubbleBoxWidth(boxStyle, contentWidth)).Render(strings.Join(lines, "\n"))
17131714
}
17141715

1715-
func (m Model) visualSelectionColors() (lipgloss.Color, lipgloss.Color) {
1716-
background := m.modeStatusColor(ModeVisual)
1717-
foreground := contrastingTextColor(background, uiTheme.BarBG, primaryFG)
1718-
return background, foreground
1716+
type visualSelectionPalette struct {
1717+
Accent lipgloss.Color
1718+
Background lipgloss.Color
1719+
Foreground lipgloss.Color
17191720
}
17201721

1721-
func visualSelectionContentStyle(style lipgloss.Style, selected bool, background, foreground lipgloss.Color) lipgloss.Style {
1722+
func (m Model) visualSelectionPalette() visualSelectionPalette {
1723+
accent := m.modeStatusColor(ModeVisual)
1724+
background := blendColors(uiTheme.BarBG, accent, visualSelectionTint)
1725+
return visualSelectionPalette{
1726+
Accent: accent,
1727+
Background: background,
1728+
Foreground: contrastingTextColor(background, uiTheme.BarBG, primaryFG),
1729+
}
1730+
}
1731+
1732+
func visualSelectionContentStyle(style lipgloss.Style, selected bool, palette visualSelectionPalette) lipgloss.Style {
17221733
if !selected {
17231734
return style
17241735
}
1725-
return style.Foreground(foreground).Background(background)
1736+
return style.Foreground(palette.Foreground).Background(palette.Background)
17261737
}
17271738

17281739
func messageBubbleBorder(active, selected bool) lipgloss.Border {
1729-
if active || selected {
1740+
if active {
17301741
return lipgloss.ThickBorder()
17311742
}
1732-
return lipgloss.RoundedBorder()
1743+
border := lipgloss.RoundedBorder()
1744+
if selected {
1745+
border.Left = "┃"
1746+
}
1747+
return border
17331748
}
17341749

17351750
func highlightedItemBorderStyle(style lipgloss.Style, border lipgloss.Color, active, selected bool) lipgloss.Style {
17361751
style = style.BorderForeground(border)
17371752
if active {
1753+
if selected {
1754+
return style.BorderForeground(focusedLine)
1755+
}
17381756
return style.
17391757
BorderTopForeground(focusedLine).
17401758
BorderLeftForeground(focusedLine).
@@ -1743,7 +1761,7 @@ func highlightedItemBorderStyle(style lipgloss.Style, border lipgloss.Color, act
17431761
}
17441762
if selected {
17451763
return style.
1746-
BorderTopForeground(border).
1764+
BorderTopForeground(borderColor).
17471765
BorderLeftForeground(border).
17481766
BorderRightForeground(borderColor).
17491767
BorderBottomForeground(borderColor)
@@ -2064,8 +2082,7 @@ func (m Model) renderAttachmentLine(media store.MediaMetadata, width int, active
20642082
style = style.Foreground(primaryFG)
20652083
}
20662084
if selected {
2067-
background, foreground := m.visualSelectionColors()
2068-
style = visualSelectionContentStyle(style, true, background, foreground)
2085+
style = visualSelectionContentStyle(style, true, m.visualSelectionPalette())
20692086
}
20702087
return style.Render(truncateDisplay(label, width))
20712088
}

0 commit comments

Comments
 (0)