Skip to content

Commit aee5d35

Browse files
committed
strengthen visual message selection
1 parent 2cf2a6d commit aee5d35

4 files changed

Lines changed: 152 additions & 15 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 and visual-mode selected message ranges use stronger terminal-safe border/shadow treatments so the hovered row, bubble, or range is easier to spot.
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.
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: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4497,6 +4497,41 @@ func TestActiveMessageBubbleUsesStrongerCursorBorder(t *testing.T) {
44974497
}
44984498
}
44994499

4500+
func TestSelectedMessageBubbleUsesHighContrastFill(t *testing.T) {
4501+
withANSIStyles(t)
4502+
model := NewModel(Options{
4503+
Config: config.Config{IndicatorVisual: "#F4D35E"},
4504+
Snapshot: store.Snapshot{
4505+
Chats: []store.Chat{{ID: "chat-1", Title: "Alice"}},
4506+
MessagesByChat: map[string][]store.Message{"chat-1": nil},
4507+
DraftsByChat: map[string]string{},
4508+
ActiveChatID: "chat-1",
4509+
},
4510+
})
4511+
message := store.Message{ID: "m-1", ChatID: "chat-1", Body: "selection fill"}
4512+
4513+
normal := model.renderMessageBubbleForViewport(message, 70, false, false, nil)
4514+
if codes := sgrCodesBeforeNth(normal, "selection fill", 0); hasSGRCode(codes, "48") {
4515+
t.Fatalf("normal message body codes = %v, want no selection background", codes)
4516+
}
4517+
4518+
selected := model.renderMessageBubbleForViewport(message, 70, false, true, nil)
4519+
selectedCodes := sgrCodesBeforeNth(selected, "selection fill", 0)
4520+
if !hasSGRCode(selectedCodes, "38") || !hasSGRCode(selectedCodes, "48") {
4521+
t.Fatalf("selected message body codes = %v, want foreground and background fill", selectedCodes)
4522+
}
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)
4527+
}
4528+
4529+
activeSelected := model.renderMessageBubbleForViewport(message, 70, true, true, nil)
4530+
if codes := sgrCodesBeforeNth(activeSelected, "selection fill", 0); !hasSGRCode(codes, "48") {
4531+
t.Fatalf("active selected message body codes = %v, want selection background", codes)
4532+
}
4533+
}
4534+
45004535
func TestChatFilterClampsCellViewport(t *testing.T) {
45014536
chats := numberedChats(8)
45024537
chats[7].Unread = 2
@@ -5542,6 +5577,7 @@ func TestVisualFooterIsMinimal(t *testing.T) {
55425577
}
55435578

55445579
func TestVisualSelectionRangeUsesStrongerBorders(t *testing.T) {
5580+
withANSIStyles(t)
55455581
model := NewModel(Options{
55465582
Snapshot: store.Snapshot{
55475583
Chats: []store.Chat{{ID: "chat-1", Title: "Alice"}},
@@ -5562,13 +5598,19 @@ func TestVisualSelectionRangeUsesStrongerBorders(t *testing.T) {
55625598
model.messageCursor = 2
55635599
model.messageScrollTop = 0
55645600

5565-
view := stripANSI(model.renderMessages(70, 14))
5601+
rendered := model.renderMessages(70, 14)
5602+
view := stripANSI(rendered)
55665603
if got := strings.Count(view, "┏"); got != 3 {
55675604
t.Fatalf("visual selected thick border count = %d, want 3\n%s", got, view)
55685605
}
55695606
if strings.Contains(view, "╭") || strings.Contains(view, "╰") {
55705607
t.Fatalf("visual selected range kept rounded borders\n%s", view)
55715608
}
5609+
for _, body := range []string{"first", "second", "third"} {
5610+
if codes := sgrCodesBeforeNth(rendered, body, 0); !hasSGRCode(codes, "48") {
5611+
t.Fatalf("visual selected body %q codes = %v, want background fill\n%s", body, codes, view)
5612+
}
5613+
}
55725614
}
55735615

55745616
func TestVisualYankCopiesSelectionToClipboard(t *testing.T) {
@@ -10739,6 +10781,17 @@ func TestLoadThemeReadsPywalColors(t *testing.T) {
1073910781
}
1074010782
}
1074110783

10784+
func TestContrastingTextColorChoosesReadableThemeColor(t *testing.T) {
10785+
dark := lipgloss.Color("#101418")
10786+
light := lipgloss.Color("#F5F7FA")
10787+
if got := contrastingTextColor(lipgloss.Color("#F4D35E"), dark, light); got != dark {
10788+
t.Fatalf("bright selection foreground = %q, want %q", got, dark)
10789+
}
10790+
if got := contrastingTextColor(lipgloss.Color("#203040"), dark, light); got != light {
10791+
t.Fatalf("dark selection foreground = %q, want %q", got, light)
10792+
}
10793+
}
10794+
1074210795
func TestFullViewDoesNotGrowPastTerminalHeightWithManyMessages(t *testing.T) {
1074310796
messages := make([]store.Message, 0, 40)
1074410797
for i := 0; i < 40; i++ {

‎internal/ui/theme.go‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package ui
22

33
import (
44
"encoding/json"
5+
"math"
56
"os"
67
"path/filepath"
8+
"strconv"
9+
"strings"
710

811
"github.com/charmbracelet/lipgloss"
912
)
@@ -107,3 +110,56 @@ func barsTransparent() bool {
107110
return true
108111
}
109112
}
113+
114+
func contrastingTextColor(background, first, second lipgloss.Color) lipgloss.Color {
115+
backgroundLuminance, ok := colorLuminance(background)
116+
if !ok {
117+
return first
118+
}
119+
firstLuminance, firstOK := colorLuminance(first)
120+
secondLuminance, secondOK := colorLuminance(second)
121+
switch {
122+
case !firstOK && !secondOK:
123+
return first
124+
case !firstOK:
125+
return second
126+
case !secondOK:
127+
return first
128+
case contrastRatio(backgroundLuminance, secondLuminance) > contrastRatio(backgroundLuminance, firstLuminance):
129+
return second
130+
default:
131+
return first
132+
}
133+
}
134+
135+
func colorLuminance(color lipgloss.Color) (float64, bool) {
136+
value := strings.TrimPrefix(strings.TrimSpace(string(color)), "#")
137+
if len(value) == 3 {
138+
value = strings.Repeat(value[0:1], 2) + strings.Repeat(value[1:2], 2) + strings.Repeat(value[2:3], 2)
139+
}
140+
if len(value) != 6 {
141+
return 0, false
142+
}
143+
rgb, err := strconv.ParseUint(value, 16, 32)
144+
if err != nil {
145+
return 0, false
146+
}
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)
153+
}
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
158+
}
159+
160+
func contrastRatio(first, second float64) float64 {
161+
if first < second {
162+
first, second = second, first
163+
}
164+
return (first + 0.05) / (second + 0.05)
165+
}

‎internal/ui/view.go‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,18 +1612,21 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16121612
border := incomingLine
16131613
fg := primaryFG
16141614
metaFG := softFG
1615+
selectionBG, selectionFG := m.visualSelectionColors()
16151616
if message.IsOutgoing {
16161617
border = outgoingLine
16171618
fg = outgoingFG
16181619
}
16191620
if selected {
1620-
border = selectedLine
1621-
fg = primaryFG
1622-
metaFG = warnFG
1621+
border = selectionBG
1622+
fg = selectionFG
1623+
metaFG = selectionFG
16231624
}
16241625
if active {
16251626
border = focusedLine
1626-
metaFG = primaryFG
1627+
if !selected {
1628+
metaFG = primaryFG
1629+
}
16271630
}
16281631

16291632
body := strings.TrimSpace(m.sanitizeDisplayBody(message.Body))
@@ -1644,6 +1647,9 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16441647
boxStyle := lipgloss.NewStyle().
16451648
Border(messageBubbleBorder(active, selected)).
16461649
Padding(0, 1)
1650+
if selected {
1651+
boxStyle = boxStyle.Background(selectionBG)
1652+
}
16471653
boxStyle = highlightedItemBorderStyle(boxStyle, border, active, selected)
16481654
maxBubbleWidth := max(6, bubbleWidth(availableWidth))
16491655
if !deleted && m.messageUsesMediaBubbleWidth(message, active) {
@@ -1661,15 +1667,18 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16611667
if deleted {
16621668
bodyStyle = lipgloss.NewStyle().Foreground(softFG).Italic(true)
16631669
}
1670+
bodyStyle = visualSelectionContentStyle(bodyStyle, selected, selectionBG, selectionFG)
16641671
messageSearchQuery := m.messageSearchQuery()
16651672

16661673
var lines []string
16671674
if sender != "" {
1668-
lines = append(lines, lipgloss.NewStyle().Foreground(metaFG).Bold(true).Render(truncateDisplay(sender, contentWidth)))
1675+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG).Bold(true), selected, selectionBG, selectionFG)
1676+
lines = append(lines, style.Render(truncateDisplay(sender, contentWidth)))
16691677
}
16701678
if !deleted {
16711679
if quote := m.messageQuoteLine(message, contentWidth); quote != "" {
1672-
lines = append(lines, lipgloss.NewStyle().Foreground(metaFG).Italic(true).Render(quote))
1680+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG).Italic(true), selected, selectionBG, selectionFG)
1681+
lines = append(lines, style.Render(quote))
16731682
}
16741683
}
16751684
if !deleted {
@@ -1678,7 +1687,7 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16781687
lines = append(lines, renderPreviewLines(preview, contentWidth, overlayPreviewVisible(preview, visibleOverlays))...)
16791688
} else {
16801689
state := m.mediaAttachmentState(message, item)
1681-
lines = append(lines, m.renderAttachmentLine(item, contentWidth, active || selected, state))
1690+
lines = append(lines, m.renderAttachmentLine(item, contentWidth, active || selected, selected, state))
16821691
}
16831692
}
16841693
}
@@ -1690,17 +1699,32 @@ func (m Model) renderMessageBubbleForViewport(message store.Message, availableWi
16901699
}
16911700
if !deleted {
16921701
if reactions := m.messageReactionLine(message, contentWidth); reactions != "" {
1693-
lines = append(lines, lipgloss.NewStyle().Foreground(metaFG).Render(reactions))
1702+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG), selected, selectionBG, selectionFG)
1703+
lines = append(lines, style.Render(reactions))
16941704
}
16951705
}
16961706
if meta != "" {
16971707
meta = truncateDisplay(meta, contentWidth)
1698-
lines = append(lines, lipgloss.NewStyle().Foreground(metaFG).Render(alignDisplay(meta, contentWidth, true)))
1708+
style := visualSelectionContentStyle(lipgloss.NewStyle().Foreground(metaFG), selected, selectionBG, selectionFG)
1709+
lines = append(lines, style.Render(alignDisplay(meta, contentWidth, true)))
16991710
}
17001711

17011712
return boxStyle.Width(bubbleBoxWidth(boxStyle, contentWidth)).Render(strings.Join(lines, "\n"))
17021713
}
17031714

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
1719+
}
1720+
1721+
func visualSelectionContentStyle(style lipgloss.Style, selected bool, background, foreground lipgloss.Color) lipgloss.Style {
1722+
if !selected {
1723+
return style
1724+
}
1725+
return style.Foreground(foreground).Background(background)
1726+
}
1727+
17041728
func messageBubbleBorder(active, selected bool) lipgloss.Border {
17051729
if active || selected {
17061730
return lipgloss.ThickBorder()
@@ -1719,8 +1743,8 @@ func highlightedItemBorderStyle(style lipgloss.Style, border lipgloss.Color, act
17191743
}
17201744
if selected {
17211745
return style.
1722-
BorderTopForeground(selectedLine).
1723-
BorderLeftForeground(selectedLine).
1746+
BorderTopForeground(border).
1747+
BorderLeftForeground(border).
17241748
BorderRightForeground(borderColor).
17251749
BorderBottomForeground(borderColor)
17261750
}
@@ -2030,7 +2054,7 @@ func (m Model) audioAttachmentState(message store.Message, item store.MediaMetad
20302054
return displayBinding(m.config.Keymap.NormalOpen, m.config.LeaderKey) + " play"
20312055
}
20322056

2033-
func (m Model) renderAttachmentLine(media store.MediaMetadata, width int, active bool, state string) string {
2057+
func (m Model) renderAttachmentLine(media store.MediaMetadata, width int, active, selected bool, state string) string {
20342058
label := m.attachmentLabel(media)
20352059
if state != "" {
20362060
label += " - " + state
@@ -2039,6 +2063,10 @@ func (m Model) renderAttachmentLine(media store.MediaMetadata, width int, active
20392063
if active {
20402064
style = style.Foreground(primaryFG)
20412065
}
2066+
if selected {
2067+
background, foreground := m.visualSelectionColors()
2068+
style = visualSelectionContentStyle(style, true, background, foreground)
2069+
}
20422070
return style.Render(truncateDisplay(label, width))
20432071
}
20442072

@@ -2736,7 +2764,7 @@ func (m Model) composerAttachmentLines(width int) []string {
27362764
LocalPath: attachment.LocalPath,
27372765
DownloadState: attachment.DownloadState,
27382766
}
2739-
lines = append(lines, m.renderAttachmentLine(media, width, true, ""))
2767+
lines = append(lines, m.renderAttachmentLine(media, width, true, false, ""))
27402768
}
27412769
return lines
27422770
}

0 commit comments

Comments
 (0)