Skip to content

Commit 0f7f824

Browse files
reobinleohenon
andauthored
feat: use theme colors for visual and copy mode selections (#83)
* feat: use theme colors for visual and copy mode selections * make visual mode a bit more vim-like * cursor doesn't change color between normal and visual modes * selected text in visual mode uses a different background than cursor * extract CopyOverlay to share overlay rendering across messages * fix visual cursor contrast on dark themes * fix: visual cursor contrast --------- Co-authored-by: leohenon <[email protected]>
1 parent 3557d71 commit 0f7f824

4 files changed

Lines changed: 141 additions & 122 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,13 @@ import {
1010
dim,
1111
fg,
1212
} from "@opentui/core"
13-
import {
14-
createEffect,
15-
createMemo,
16-
onMount,
17-
createSignal,
18-
onCleanup,
19-
on,
20-
Show,
21-
Switch,
22-
Match,
23-
} from "solid-js"
13+
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
2414
import "opentui-spinner/solid"
2515
import path from "path"
2616
import { fileURLToPath } from "url"
2717
import { Filesystem } from "@/util/filesystem"
2818
import { useLocal } from "@tui/context/local"
29-
import { tint, useTheme } from "@tui/context/theme"
19+
import { selectedForeground, tint, useTheme } from "@tui/context/theme"
3020
import { EmptyBorder, SplitBorder } from "@tui/component/border"
3121
import { useSDK } from "@tui/context/sdk"
3222
import { useRoute } from "@tui/context/route"
@@ -290,10 +280,13 @@ export function Prompt(props: PromptProps) {
290280
if (props.disabled || vimState.isCopy()) {
291281
input.cursorColor = theme.backgroundElement
292282
input.showCursor = false
293-
} else {
294-
input.cursorColor = theme.text
295-
input.showCursor = true
283+
return
296284
}
285+
const visual = vimState.isVisual()
286+
input.cursorColor = theme.text
287+
input.showCursor = !visual
288+
input.selectionBg = visual ? theme.secondary : undefined
289+
input.selectionFg = visual ? selectedForeground(theme, theme.secondary) : undefined
297290
})
298291

299292
createEffect((prev: boolean | undefined) => {
@@ -1755,14 +1748,25 @@ export function Prompt(props: PromptProps) {
17551748
input.scrollY,
17561749
input.height,
17571750
)
1758-
if (!rows.length) return
1759-
const bg = input.selectionBg ?? input.textColor
1760-
const fg =
1761-
input.selectionFg ??
1762-
(input.backgroundColor.a > 0 ? input.backgroundColor : RGBA.fromInts(0, 0, 0))
1763-
rows.forEach((row) => {
1764-
buffer.setCell(input.x, input.y + row, " ", fg, bg)
1765-
})
1751+
if (rows.length) {
1752+
const bg = input.selectionBg ?? input.textColor
1753+
const fg =
1754+
input.selectionFg ??
1755+
(input.backgroundColor.a > 0 ? input.backgroundColor : RGBA.fromInts(0, 0, 0))
1756+
rows.forEach((row) => {
1757+
buffer.setCell(input.x, input.y + row, " ", fg, bg)
1758+
})
1759+
}
1760+
if (input.visualCursor.visualRow < 0 || input.visualCursor.visualRow >= input.height) return
1761+
if (input.visualCursor.visualCol < 0 || input.visualCursor.visualCol >= input.width) return
1762+
// recolor the cursor cell in place; setCell would clobber the underlying glyph
1763+
const cursorOffset =
1764+
((input.y + input.visualCursor.visualRow) * buffer.width +
1765+
input.x +
1766+
input.visualCursor.visualCol) *
1767+
4
1768+
buffer.buffers.fg.set(selectedForeground(theme, theme.text).buffer.subarray(0, 4), cursorOffset)
1769+
buffer.buffers.bg.set(theme.text.buffer.subarray(0, 4), cursorOffset)
17661770
}
17671771
}
17681772
props.ref?.(ref)

packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,7 @@ function nextCharwiseSpan(text: string, cursor: number, c: NextClassification):
239239
return { start: cursor, end }
240240
}
241241

242-
export function nextParagraphOperation(
243-
textarea: TextareaRenderable,
244-
operation: ParagraphOperation,
245-
): ParagraphResult {
242+
export function nextParagraphOperation(textarea: TextareaRenderable, operation: ParagraphOperation): ParagraphResult {
246243
const text = textarea.plainText
247244
const cursor = textarea.cursorOffset
248245
if (text.length === 0) return { span: null, register: null }
@@ -319,8 +316,7 @@ export function wordEnd(text: string, offset: number, big: boolean) {
319316
if (pos >= text.length) pos = text.length - 1
320317

321318
const startClass = wordClass(text[pos], big)
322-
const atRunEnd =
323-
startClass === "blank" || pos + 1 >= text.length || wordClass(text[pos + 1], big) !== startClass
319+
const atRunEnd = startClass === "blank" || pos + 1 >= text.length || wordClass(text[pos + 1], big) !== startClass
324320

325321
if (atRunEnd) {
326322
pos++
@@ -771,7 +767,7 @@ export function syncSelection(textarea: TextareaRenderable, anchor: number, line
771767
textarea.cursorOffset = forward ? hi : lo
772768
ta.updateSelectionForMovement(true, false)
773769
textarea.cursorOffset = cursor
774-
textarea.editorView.setSelection(lo, hi)
770+
textarea.editorView.setSelection(lo, hi, textarea.selectionBg, textarea.selectionFg)
775771
}
776772

777773
export function clearSelection(textarea: TextareaRenderable) {

packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ const empty: CopyState = {
4949

5050
const segmenter = new Intl.Segmenter()
5151

52+
type Endpoint = { idx: number; col: number }
53+
function orderEndpoints(a: Endpoint, b: Endpoint): { start: Endpoint; end: Endpoint } {
54+
const aFirst = a.idx < b.idx || (a.idx === b.idx && a.col <= b.col)
55+
return aFirst ? { start: a, end: b } : { start: b, end: a }
56+
}
57+
5258
export function createCopyMode(input: {
5359
scroll: () => ScrollBoxRenderable
5460
messages: Accessor<{ id: string; role: string }[]>
@@ -449,10 +455,8 @@ export function createCopyMode(input: {
449455
.getChildren()
450456
.map((c) => [c.id, c]),
451457
)
452-
const a = s.anchor
453458
const h = { idx: s.idx, col: s.col }
454-
const start = a.idx <= h.idx ? a : h
455-
const end = a.idx <= h.idx ? h : a
459+
const { start, end } = orderEndpoints(s.anchor, h)
456460
if (s.visual === "line") {
457461
return Array.from({ length: end.idx - start.idx + 1 }, (_, i) => list[start.idx + i])
458462
.filter((row): row is CopyRow => !!row)
@@ -601,11 +605,21 @@ export function createCopyMode(input: {
601605
.getChildren()
602606
.map((c) => [c.id, c]),
603607
)
604-
const a = s.anchor
605608
const h = { idx: s.idx, col: s.col }
606-
const start = a.idx <= h.idx ? a : h
607-
const end = a.idx <= h.idx ? h : a
609+
const { start, end } = orderEndpoints(s.anchor, h)
608610
const out = new Map<string, CopyHighlight[]>()
611+
const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number) => {
612+
if (left > right) return
613+
const entry = {
614+
line: row.line,
615+
left,
616+
right,
617+
text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)),
618+
}
619+
const arr = out.get(row.id)
620+
if (arr) arr.push(entry)
621+
else out.set(row.id, [entry])
622+
}
609623
for (let i = start.idx; i <= end.idx; i++) {
610624
const r = list[i]
611625
if (!r) continue
@@ -616,18 +630,31 @@ export function createCopyMode(input: {
616630
s.visual === "line" ? min : i === start.idx && i === end.idx ? start.col : i === start.idx ? start.col : min
617631
const right =
618632
s.visual === "line" ? max : i === start.idx && i === end.idx ? end.col : i === end.idx ? end.col : max
619-
const cur = out.get(r.id) ?? []
620-
cur.push({
621-
line: r.line,
622-
left,
623-
right,
624-
text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)),
625-
})
626-
out.set(r.id, cur)
633+
if (i !== h.idx) {
634+
addHighlight(r, min, text, left, right)
635+
continue
636+
}
637+
// cursor cell is painted separately by CopyOverlay so the cursor keeps its theme.text color
638+
addHighlight(r, min, text, left, h.col - 1)
639+
addHighlight(r, min, text, h.col + 1, right)
627640
}
628641
return out
629642
})
630643

644+
const cursorText = createMemo(() => {
645+
const s = state()
646+
if (!s.active) return " "
647+
const row = rows()[s.idx]
648+
if (!row) return " "
649+
const text = copyText()
650+
let col = 0
651+
for (const seg of segmenter.segment(text)) {
652+
if (col >= s.col) return seg.segment
653+
col += Bun.stringWidth(seg.segment)
654+
}
655+
return " "
656+
})
657+
631658
return {
632659
prompt: {
633660
enter,
@@ -656,5 +683,6 @@ export function createCopyMode(input: {
656683
active: () => state().active,
657684
clamp,
658685
state,
686+
cursorText,
659687
}
660688
}

0 commit comments

Comments
 (0)