feat(tui): replace footer typewriter with cross-dissolve
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user