feat(tui): replace footer typewriter with cross-dissolve

This commit is contained in:
Kit Langton
2026-07-29 19:21:02 -04:00
parent 2a449db7af
commit c0526d403e
5 changed files with 111 additions and 73 deletions
@@ -53,9 +53,10 @@ export function DialogProject() {
onSelect={(option) => {
dialog.clear()
if (option.value === current()) return
// Navigating while already home would remount the footer mid-animation.
if (route.data.type !== "home") route.navigate({ type: "home" })
void data.location
.setDefault(option.value)
.then(() => route.navigate({ type: "home" }))
.catch((error) =>
toast.show({ variant: "error", title: "Failed to switch project", message: errorMessage(error) }),
)
@@ -1,29 +1,21 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { FilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
import { createTypewriter } from "../../ui/typewriter"
import { DissolveFilePath } from "../../ui/dissolve-file-path"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const directory = createMemo(() =>
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
const typed = createTypewriter(directory)
return (
<Show when={typed.text !== undefined}>
<box flexDirection="row" flexShrink={1}>
<FilePath
value={typed.text ?? ""}
maxWidth={props.maxWidth}
fg={typed.active ? props.context.theme.text.default : props.context.theme.text.subdued}
/>
<Show when={typed.active}>
<text fg={props.context.theme.text.default}></text>
</Show>
</box>
</Show>
<DissolveFilePath
value={directory()}
maxWidth={props.maxWidth}
fg={props.context.theme.text.subdued}
bg={props.context.theme.background.default}
/>
)
}
@@ -1,26 +1,18 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { FilePath } from "../../ui/file-path"
import { createTypewriter } from "../../ui/typewriter"
import { createMemo } from "solid-js"
import { DissolveFilePath } from "../../ui/dissolve-file-path"
function View(props: { context: Plugin.Context }) {
const directory = createMemo(() =>
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
const typed = createTypewriter(directory)
return (
<Show when={typed.text !== undefined}>
<box flexDirection="row" flexShrink={1}>
<FilePath
value={typed.text ?? ""}
maxWidth={38}
fg={typed.active ? props.context.theme.text.default : props.context.theme.text.subdued}
/>
<Show when={typed.active}>
<text fg={props.context.theme.text.default}></text>
</Show>
</box>
</Show>
<DissolveFilePath
value={directory()}
maxWidth={38}
fg={props.context.theme.text.subdued}
bg={props.context.theme.background.default}
/>
)
}
@@ -0,0 +1,94 @@
import { RGBA } from "@opentui/core"
import { createEffect, createMemo, Index, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useConfig } from "../config"
import { FilePath, truncateFilePath } from "./file-path"
const DURATION = 600
const FEATHER = 8
// FilePath that cross-dissolves left to right when its value changes. The old
// path fades out while the new one fades in behind a soft sweep. The initial
// value renders immediately; only subsequent changes animate.
export function DissolveFilePath(props: {
value: string | undefined
maxWidth: number
fg: RGBA
bg: RGBA
basenameFg?: RGBA
}) {
const config = useConfig().data
const [store, setStore] = createStore({
text: props.value,
previous: undefined as string | undefined,
progress: 1,
})
createEffect(
on(
() => props.value,
(text) => {
// The source can flicker to undefined while a new location syncs;
// retain the last path so the change animates as one transition.
if (text === undefined || text === store.text) return
if (store.text === undefined || !(config.animations ?? true)) {
setStore({ text, previous: undefined, progress: 1 })
return
}
setStore({ text, previous: store.text, progress: 0 })
const started = performance.now()
const timer = setInterval(() => {
const progress = Math.min(1, (performance.now() - started) / DURATION)
setStore("progress", progress)
if (progress >= 1) {
clearInterval(timer)
setStore("previous", undefined)
}
}, 33)
onCleanup(() => clearInterval(timer))
},
{ defer: true },
),
)
const cells = createMemo(() => {
const previous = [...truncateFilePath(store.previous ?? "", props.maxWidth)]
const next = [...truncateFilePath(store.text ?? "", props.maxWidth)]
const width = Math.max(previous.length, next.length)
const sweep = store.progress * (width + FEATHER)
return Array.from({ length: width }, (_, index) => {
const t = Math.min(1, Math.max(0, (sweep - index) / FEATHER))
// Inside the sweep band the cell dips toward the background before the
// new character surfaces, reading as a per-column cross-dissolve.
const fade = Math.abs(t - 0.5) * 2
return {
char: (t < 0.5 ? previous[index] : next[index]) ?? " ",
fg: mix(props.bg, props.fg, fade),
}
})
})
return (
<Show when={store.text !== undefined}>
<Show
when={store.previous !== undefined}
fallback={
<FilePath value={store.text ?? ""} maxWidth={props.maxWidth} fg={props.fg} basenameFg={props.basenameFg} />
}
>
<text wrapMode="none">
<Index each={cells()}>{(cell) => <span style={{ fg: cell().fg }}>{cell().char}</span>}</Index>
</text>
</Show>
</Show>
)
}
function mix(from: RGBA, to: RGBA, t: number) {
return RGBA.fromValues(
from.r + (to.r - from.r) * t,
from.g + (to.g - from.g) * t,
from.b + (to.b - from.b) * t,
from.a + (to.a - from.a) * t,
)
}
-41
View File
@@ -1,41 +0,0 @@
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useConfig } from "../config"
// Retypes a string like a typewriter whenever the source changes. The initial
// value renders immediately; only subsequent changes animate. While `active`,
// callers should render a cursor and a brighter style, then settle back down.
export function createTypewriter(source: () => string | undefined) {
const config = useConfig().data
const [store, setStore] = createStore({
text: source(),
active: false,
})
createEffect(
on(
source,
(text) => {
if (text === undefined || !(config.animations ?? true)) {
setStore({ text, active: false })
return
}
const timeouts: ReturnType<typeof setTimeout>[] = []
setStore({ text: "", active: true })
let i = 0
const type = () => {
if (i < text.length) {
i++
setStore("text", text.slice(0, i))
timeouts.push(setTimeout(type, Math.random() < 0.1 ? 40 + Math.random() * 40 : 8 + Math.random() * 18))
return
}
timeouts.push(setTimeout(() => setStore("active", false), 1200))
}
timeouts.push(setTimeout(type, 120))
onCleanup(() => timeouts.forEach(clearTimeout))
},
{ defer: true },
),
)
return store
}