mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: terminal-native torrent discovery TUI
Curated, multi-source torrent search and download from the terminal: concurrent search across built-in sources with streamed, source-tagged results; a download-only webtorrent engine with true pause/resume and restart recovery; magnet paste; and a calm Ink + React TUI. No indexer server, no setup.
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useStore, useQueueItems, useQueueHistory, type DownloadFocus } from "../store";
|
||||
import { Panel } from "./Panel";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { wrapStep, windowStart } from "../move";
|
||||
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
|
||||
import {
|
||||
cleanText,
|
||||
formatBytes,
|
||||
formatBytesPerSec,
|
||||
formatEtaShort,
|
||||
formatRelative,
|
||||
truncate,
|
||||
} from "../../util/format";
|
||||
import type { QueueItem } from "../../download/types";
|
||||
import type { HistoryItem } from "../../download/history";
|
||||
|
||||
const ROWS_PER_ACTIVE = 2;
|
||||
const MARK = 2;
|
||||
|
||||
const PAUSED = "#7c7785";
|
||||
|
||||
function statusColor(status: QueueItem["status"]): string {
|
||||
if (status === "failed") return COLOR.bad;
|
||||
if (status === "paused") return PAUSED;
|
||||
return COLOR.accent;
|
||||
}
|
||||
|
||||
function statusIcon(status: QueueItem["status"]): string {
|
||||
if (status === "failed") return ICON.error;
|
||||
if (status === "paused") return ICON.pause;
|
||||
return ICON.down;
|
||||
}
|
||||
|
||||
function rightStats(it: QueueItem): string {
|
||||
if (it.status === "downloading") {
|
||||
const speed = formatBytesPerSec(it.speed) || "…";
|
||||
const eta = it.eta ? ` ${formatEtaShort(it.eta)}` : "";
|
||||
return `${it.progress}% ${speed} ${ICON.peer}${it.peers}${eta}`;
|
||||
}
|
||||
if (it.status === "paused") return `paused ${it.progress}%`;
|
||||
return truncate(it.error || "failed", 28);
|
||||
}
|
||||
|
||||
export function Downloads() {
|
||||
const { queue, region, contentWidth, listRows, startDownload, setDownloadFocus } = useStore();
|
||||
const active = useQueueItems(queue);
|
||||
const recent = useQueueHistory(queue);
|
||||
const focused = region === "content";
|
||||
|
||||
const total = active.length + recent.length;
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const clamped = Math.min(cursor, Math.max(0, total - 1));
|
||||
const inActive = clamped < active.length;
|
||||
const recentCursor = clamped - active.length;
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow) setCursor(wrapStep(clamped, -1, total));
|
||||
else if (key.downArrow) setCursor(wrapStep(clamped, 1, total));
|
||||
else if (input === "f") queue.retryFailed();
|
||||
else if (input === "x") queue.clearHistory();
|
||||
else if (inActive) {
|
||||
const it = active[clamped];
|
||||
if (!it) return;
|
||||
if (input === "c") queue.cancel(it.id);
|
||||
else if (input === "p") queue.togglePause(it.id);
|
||||
} else {
|
||||
const h = recent[recentCursor];
|
||||
if (!h) return;
|
||||
if (key.return || input === "d")
|
||||
startDownload({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
magnet: h.magnet,
|
||||
source: h.source,
|
||||
sizeBytes: h.sizeBytes,
|
||||
});
|
||||
else if (input === "c") queue.removeHistory(h.id);
|
||||
}
|
||||
},
|
||||
{ isActive: focused && total > 0 },
|
||||
);
|
||||
|
||||
let focusKind: DownloadFocus | null = null;
|
||||
if (focused && total > 0) {
|
||||
if (!inActive) focusKind = "recent";
|
||||
else {
|
||||
const st = active[clamped]?.status;
|
||||
if (st === "downloading" || st === "paused" || st === "failed") focusKind = st;
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
setDownloadFocus(focusKind);
|
||||
return () => setDownloadFocus(null);
|
||||
}, [focusKind, setDownloadFocus]);
|
||||
|
||||
const panelH = Math.max(5, listRows - 1);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<Panel title="downloads" width={contentWidth} focused={focused} height={panelH}>
|
||||
<Text dimColor>No downloads yet. Find something and press d to grab it.</Text>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const hasActive = active.length > 0;
|
||||
const hasRecent = recent.length > 0;
|
||||
const headerRows = hasRecent ? 1 : 0;
|
||||
const ceiling = Math.max(1, panelH - 1);
|
||||
|
||||
let gapRows = hasActive && hasRecent ? 1 : 0;
|
||||
let maxActive = 0;
|
||||
let maxRecent = 0;
|
||||
if (!hasRecent) {
|
||||
maxActive = Math.max(1, Math.floor(ceiling / ROWS_PER_ACTIVE));
|
||||
} else if (!hasActive) {
|
||||
maxRecent = Math.max(1, ceiling - headerRows);
|
||||
} else {
|
||||
let budget = ceiling - headerRows - gapRows;
|
||||
if (budget < ROWS_PER_ACTIVE + 1) {
|
||||
gapRows = 0;
|
||||
budget = ceiling - headerRows;
|
||||
}
|
||||
const activeRowCap = Math.max(ROWS_PER_ACTIVE, Math.floor(budget * 0.55));
|
||||
maxActive = Math.min(active.length, Math.max(1, Math.floor(activeRowCap / ROWS_PER_ACTIVE)));
|
||||
maxRecent = Math.max(1, budget - maxActive * ROWS_PER_ACTIVE);
|
||||
}
|
||||
|
||||
const activeStart = windowStart(inActive ? clamped : 0, active.length, maxActive);
|
||||
const activeVisible = active.slice(activeStart, activeStart + maxActive);
|
||||
const recentStart = windowStart(inActive ? 0 : recentCursor, recent.length, maxRecent);
|
||||
const recentVisible = recent.slice(recentStart, recentStart + maxRecent);
|
||||
|
||||
const inner = contentWidth - 4;
|
||||
const gap = 2;
|
||||
const barW = Math.max(8, Math.min(28, Math.floor(inner * 0.4)));
|
||||
const statsW = Math.max(6, inner - MARK - GUTTER - barW - gap);
|
||||
|
||||
const count = hasActive ? `(${active.length})` : undefined;
|
||||
|
||||
return (
|
||||
<Panel title="downloads" width={contentWidth} focused={focused} count={count} height={panelH}>
|
||||
{activeVisible.map((it, i) => {
|
||||
const here = activeStart + i === clamped && focused && inActive;
|
||||
const sc = statusColor(it.status);
|
||||
const ss = SOURCE_STYLE[it.source ?? "fitgirl"];
|
||||
return (
|
||||
<Box key={it.id} flexDirection="column">
|
||||
<Box>
|
||||
<Box width={MARK} flexShrink={0}>
|
||||
<Text color={COLOR.accent} bold>
|
||||
{here ? ICON.pointer : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={GUTTER} flexShrink={0}>
|
||||
<Text color={sc}>{statusIcon(it.status)}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Text
|
||||
wrap="truncate-end"
|
||||
bold={here}
|
||||
color={here ? COLOR.accent : undefined}
|
||||
dimColor={!here}
|
||||
>
|
||||
{cleanText(it.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text dimColor>{it.totalBytes > 0 ? formatBytes(it.totalBytes) : "-"}</Text>
|
||||
</Box>
|
||||
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={it.source ? ss.color : undefined} dimColor={!it.source || !here}>
|
||||
{it.source ? ss.tag : "mag"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box width={MARK + GUTTER} flexShrink={0} />
|
||||
<ProgressBar
|
||||
pct={it.progress}
|
||||
width={barW}
|
||||
color={sc}
|
||||
animate={it.status === "downloading"}
|
||||
/>
|
||||
<Box marginLeft={gap} flexShrink={0}>
|
||||
<Text dimColor>{truncate(rightStats(it), statsW)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasRecent ? (
|
||||
<Box marginTop={gapRows ? 1 : 0}>
|
||||
<Text dimColor>{`Recently downloaded${recent.length > 1 ? ` (${recent.length})` : ""}`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{recentVisible.map((h: HistoryItem, i) => {
|
||||
const here = recentStart + i === recentCursor && focused && !inActive;
|
||||
const ss = SOURCE_STYLE[h.source ?? "fitgirl"];
|
||||
const when = formatRelative(h.completedAt / 1000);
|
||||
return (
|
||||
<Box key={h.id}>
|
||||
<Box width={MARK} flexShrink={0}>
|
||||
<Text color={COLOR.accent} bold>
|
||||
{here ? ICON.pointer : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={GUTTER} flexShrink={0}>
|
||||
<Text color={COLOR.good} dimColor={!here}>
|
||||
{ICON.done}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Text
|
||||
wrap="truncate-end"
|
||||
bold={here}
|
||||
color={here ? COLOR.accent : undefined}
|
||||
dimColor={!here}
|
||||
>
|
||||
{cleanText(h.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text dimColor>{h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"}</Text>
|
||||
</Box>
|
||||
<Box width={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text dimColor>{when || "-"}</Text>
|
||||
</Box>
|
||||
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={h.source ? ss.color : undefined} dimColor={!h.source || !here}>
|
||||
{h.source ? ss.tag : "mag"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { COLOR } from "../theme";
|
||||
import type { Hint } from "../keymap";
|
||||
|
||||
export function Footer({ hints }: { hints: Hint[] }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
{hints.map((h, i) => (
|
||||
<Text key={h.keys + h.label}>
|
||||
{i > 0 ? <Text dimColor>{" "}</Text> : null}
|
||||
<Text color={COLOR.alt}>{h.keys}</Text>
|
||||
<Text dimColor>{` ${h.label}`}</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { HELP_GROUPS } from "../keymap";
|
||||
import { useStore } from "../store";
|
||||
import { COLOR, RULE, lerpHex } from "../theme";
|
||||
|
||||
const CARD_BORDER = lerpHex(COLOR.accent, RULE, 0.55);
|
||||
|
||||
const KEY_GAP = 2;
|
||||
const COL_GAP = 2;
|
||||
const KEY_W = HELP_GROUPS.map(
|
||||
(g) => Math.max(...g.hints.map((h) => h.keys.length)) + KEY_GAP,
|
||||
);
|
||||
const COL_W = HELP_GROUPS.map(
|
||||
(g, i) => KEY_W[i]! + Math.max(...g.hints.map((h) => h.label.length)),
|
||||
);
|
||||
const CARD_W =
|
||||
COL_W.reduce((a, b) => a + b, 0) + (HELP_GROUPS.length - 1) * COL_GAP + 4;
|
||||
const KEY_W_STACKED = Math.max(...KEY_W);
|
||||
|
||||
export function HelpOverlay() {
|
||||
const { cols } = useStore();
|
||||
const columns = cols >= CARD_W;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
alignSelf="flex-start"
|
||||
borderStyle="round"
|
||||
borderColor={CARD_BORDER}
|
||||
paddingX={columns ? 1 : 2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold color={COLOR.accent}>
|
||||
Keyboard
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection={columns ? "row" : "column"}>
|
||||
{HELP_GROUPS.map((group, gi) => (
|
||||
<Box
|
||||
key={group.title}
|
||||
flexDirection="column"
|
||||
width={columns ? COL_W[gi] : undefined}
|
||||
marginRight={columns && gi < HELP_GROUPS.length - 1 ? COL_GAP : 0}
|
||||
marginTop={!columns && gi > 0 ? 1 : 0}
|
||||
>
|
||||
<Text bold>{group.title}</Text>
|
||||
{group.hints.map((h) => (
|
||||
<Box key={h.keys + h.label}>
|
||||
<Box width={columns ? KEY_W[gi] : KEY_W_STACKED} flexShrink={0}>
|
||||
<Text color={COLOR.alt}>{h.keys}</Text>
|
||||
</Box>
|
||||
<Text dimColor>{h.label}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press ? or esc to close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { LOGO_LINES, SPROUT_CELLS } from "../logo";
|
||||
import { COLOR, lerpHex } from "../theme";
|
||||
|
||||
const HIGHLIGHT = "#ffffff";
|
||||
const TOP = COLOR.bright;
|
||||
const BASE = "#7c5cd6";
|
||||
const SHADE = "#4c3a8a";
|
||||
const SPROUT_COLOR = "#5ae87a";
|
||||
|
||||
function getSheen(t: number): string {
|
||||
if (t < 0.15) return lerpHex(HIGHLIGHT, TOP, t / 0.15);
|
||||
if (t < 0.4) return lerpHex(TOP, COLOR.accent, (t - 0.15) / 0.25);
|
||||
if (t < 0.7) return lerpHex(COLOR.accent, BASE, (t - 0.4) / 0.3);
|
||||
return lerpHex(BASE, SHADE, (t - 0.7) / 0.3);
|
||||
}
|
||||
|
||||
export function Logo() {
|
||||
const rows = LOGO_LINES.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{LOGO_LINES.map((line, row) => {
|
||||
const textRow = Math.max(0, row - 1);
|
||||
const textRows = Math.max(1, rows - 1);
|
||||
const tY = textRow / (textRows - 1 || 1);
|
||||
const chars = [...line];
|
||||
const last = Math.max(1, chars.length - 1);
|
||||
|
||||
return (
|
||||
<Box key={row}>
|
||||
{chars.map((ch, i) => {
|
||||
if (ch === " ") return <Text key={i}> </Text>;
|
||||
|
||||
if (SPROUT_CELLS.has(`${row},${i}`)) {
|
||||
return (
|
||||
<Text key={i} bold color={SPROUT_COLOR}>
|
||||
{ch}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const tX = i / last;
|
||||
const factor = (tX + tY) / 2;
|
||||
|
||||
return (
|
||||
<Text key={i} bold color={getSheen(factor)}>
|
||||
{ch}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { COLOR, RULE } from "../theme";
|
||||
|
||||
interface PanelProps {
|
||||
title: string;
|
||||
width: number;
|
||||
focused?: boolean;
|
||||
count?: string;
|
||||
height?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Panel({ title, width, focused, count, height, children }: PanelProps) {
|
||||
const color = focused ? COLOR.accent : RULE;
|
||||
const w = Math.max(10, width);
|
||||
const cap = title.charAt(0).toUpperCase() + title.slice(1);
|
||||
const label = count ? `${cap} ${count}` : cap;
|
||||
const fill = Math.max(0, w - 5 - label.length);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={w}>
|
||||
<Box>
|
||||
<Text color={color}>{"╭─ "}</Text>
|
||||
<Text bold color={color}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text color={color}>{` ${"─".repeat(fill)}╮`}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={w}
|
||||
height={height}
|
||||
flexGrow={height ? 0 : 1}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
borderColor={color}
|
||||
paddingX={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "ink";
|
||||
import { COLOR, RULE, lerpHex } from "../theme";
|
||||
|
||||
const DEEP = "#7c5cd6";
|
||||
const SHEEN_PEAK = "#f4efff";
|
||||
const RADIUS = 3.5;
|
||||
const GAP = 8;
|
||||
const MAX_SHEEN = 0.9;
|
||||
const TICK_MS = 90;
|
||||
|
||||
interface Run {
|
||||
color: string;
|
||||
len: number;
|
||||
}
|
||||
|
||||
function ramp(t: number, deep: string, mid: string, bright: string): string {
|
||||
return t <= 0.5 ? lerpHex(deep, mid, t / 0.5) : lerpHex(mid, bright, (t - 0.5) / 0.5);
|
||||
}
|
||||
|
||||
function runs(colors: string[]): Run[] {
|
||||
const out: Run[] = [];
|
||||
for (const c of colors) {
|
||||
const prev = out[out.length - 1];
|
||||
if (prev && prev.color === c) prev.len++;
|
||||
else out.push({ color: c, len: 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function paint(rs: Run[]) {
|
||||
return rs.map((r, i) => (
|
||||
<Text key={i} color={r.color}>
|
||||
{"█".repeat(r.len)}
|
||||
</Text>
|
||||
));
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
pct,
|
||||
width,
|
||||
color = COLOR.accent,
|
||||
animate = false,
|
||||
}: {
|
||||
pct: number;
|
||||
width: number;
|
||||
color?: string;
|
||||
animate?: boolean;
|
||||
}) {
|
||||
const clamped = Math.max(0, Math.min(100, pct));
|
||||
const filled = Math.round((clamped / 100) * width);
|
||||
const empty = Math.max(0, width - filled);
|
||||
const denom = Math.max(1, width - 1);
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!animate) return;
|
||||
const timer = setInterval(() => setTick((v) => v + 1), TICK_MS);
|
||||
timer.unref?.();
|
||||
return () => clearInterval(timer);
|
||||
}, [animate]);
|
||||
|
||||
const track = empty > 0 ? <Text color={RULE}>{"░".repeat(empty)}</Text> : null;
|
||||
|
||||
if (filled === 0) return <Text>{track}</Text>;
|
||||
|
||||
if (!animate) {
|
||||
const deep = lerpHex(color, "#000000", 0.3);
|
||||
const bright = lerpHex(color, COLOR.text, 0.35);
|
||||
const cells = Array.from({ length: filled }, (_, i) => ramp(i / denom, deep, color, bright));
|
||||
return (
|
||||
<Text>
|
||||
{paint(runs(cells))}
|
||||
{track}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const period = Math.ceil(width + RADIUS * 2) + GAP;
|
||||
const center = (tick % period) - RADIUS;
|
||||
const cells = Array.from({ length: filled }, (_, i) => {
|
||||
let c = ramp(i / denom, DEEP, COLOR.accent, COLOR.bright);
|
||||
const d = Math.abs(i - center);
|
||||
if (d < RADIUS) {
|
||||
const intensity = 0.5 * (1 + Math.cos((Math.PI * d) / RADIUS)) * MAX_SHEEN;
|
||||
c = lerpHex(c, SHEEN_PEAK, intensity);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{paint(runs(cells))}
|
||||
{track}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useStore, CATEGORIES } from "../store";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { Panel } from "./Panel";
|
||||
import { Rule } from "./Rule";
|
||||
import { useConcurrentSearch } from "../hooks/useConcurrentSearch";
|
||||
import { getSource, SOURCES } from "../../sources/registry";
|
||||
import { wrapStep, windowStart } from "../move";
|
||||
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
|
||||
import { cleanText, formatBytes, formatRelative, truncate } from "../../util/format";
|
||||
import type { Source, TorrentResult } from "../../sources/types";
|
||||
|
||||
type Mode = "list" | "search" | "detail";
|
||||
|
||||
const PLACEHOLDER = "Search, or paste a magnet…";
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Box width={9} flexShrink={0}>
|
||||
<Text dimColor>{label}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0}>{value}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ r, width }: { r: TorrentResult; width: number }) {
|
||||
const ss = SOURCE_STYLE[r.source];
|
||||
const date = formatRelative(r.added);
|
||||
const health =
|
||||
r.seeders || r.leechers ? (
|
||||
<Text>
|
||||
<Text color={r.seeders > 0 ? COLOR.good : undefined} bold={r.seeders > 0}>
|
||||
{r.seeders}
|
||||
</Text>
|
||||
<Text dimColor>{` seeders ${ICON.dot} ${r.leechers} leechers`}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>unknown</Text>
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<Text bold color={COLOR.text} wrap="truncate-end">
|
||||
{cleanText(r.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2}>
|
||||
<Text color={ss.color} bold>
|
||||
{ss.tag}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Rule width={width} />
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<DetailRow
|
||||
label="Size"
|
||||
value={
|
||||
r.sizeBytes > 0 ? (
|
||||
<Text color={COLOR.text}>{formatBytes(r.sizeBytes)}</Text>
|
||||
) : (
|
||||
<Text dimColor>unknown</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Health" value={health} />
|
||||
{r.numFiles ? (
|
||||
<DetailRow label="Files" value={<Text dimColor>{String(r.numFiles)}</Text>} />
|
||||
) : null}
|
||||
{date ? <DetailRow label="Added" value={<Text dimColor>{date}</Text>} /> : null}
|
||||
<DetailRow
|
||||
label="Hash"
|
||||
value={
|
||||
<Text color={COLOR.alt} dimColor wrap="truncate-end">
|
||||
{r.infoHash}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={COLOR.accent} bold>
|
||||
d
|
||||
</Text>
|
||||
<Text color={COLOR.text}> Download</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.alt}>esc</Text>
|
||||
<Text dimColor> back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Results() {
|
||||
const {
|
||||
query,
|
||||
submitQuery,
|
||||
section,
|
||||
region,
|
||||
setCaptureMode,
|
||||
startDownload,
|
||||
contentWidth,
|
||||
listRows,
|
||||
} = useStore();
|
||||
|
||||
const search = useConcurrentSearch(query);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const cat = CATEGORIES.find((c) => c.key === section);
|
||||
if (!cat?.group) return search.results;
|
||||
return search.results.filter((r) => getSource(r.source).group === cat.group);
|
||||
}, [search.results, section]);
|
||||
|
||||
const focused = region === "content";
|
||||
const [mode, setMode] = useState<Mode>("list");
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [detail, setDetail] = useState<TorrentResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(0);
|
||||
}, [results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
setCaptureMode(mode === "search" ? "text" : mode === "detail" ? "esc" : "none");
|
||||
return () => setCaptureMode("none");
|
||||
}, [mode, focused, setCaptureMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setMode("list");
|
||||
}, [focused]);
|
||||
|
||||
const clamped = Math.min(cursor, Math.max(0, results.length - 1));
|
||||
|
||||
const searchH = 3;
|
||||
const panelOuter = Math.max(5, listRows - searchH - 1);
|
||||
const listHeight = Math.max(3, panelOuter - 4);
|
||||
const pageJump = Math.max(1, listHeight - 1);
|
||||
|
||||
const openDownload = (r: TorrentResult): void =>
|
||||
startDownload({
|
||||
id: r.infoHash,
|
||||
name: r.name,
|
||||
magnet: r.magnet,
|
||||
source: r.source,
|
||||
sizeBytes: r.sizeBytes,
|
||||
});
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input === "/") {
|
||||
setMode("search");
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) return;
|
||||
if (key.upArrow) setCursor(wrapStep(clamped, -1, results.length));
|
||||
else if (key.downArrow) setCursor(wrapStep(clamped, 1, results.length));
|
||||
else if (key.pageUp) setCursor(Math.max(0, clamped - pageJump));
|
||||
else if (key.pageDown) setCursor(Math.min(results.length - 1, clamped + pageJump));
|
||||
else if (key.return) {
|
||||
const r = results[clamped];
|
||||
if (r) {
|
||||
setDetail(r);
|
||||
setMode("detail");
|
||||
}
|
||||
} else if (input === "d") {
|
||||
const r = results[clamped];
|
||||
if (r) openDownload(r);
|
||||
}
|
||||
},
|
||||
{ isActive: focused && mode === "list" },
|
||||
);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
setMode("list");
|
||||
setDetail(null);
|
||||
} else if (input === "d" && detail) openDownload(detail);
|
||||
},
|
||||
{ isActive: focused && mode === "detail" },
|
||||
);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) setMode("list");
|
||||
},
|
||||
{ isActive: focused && mode === "search" },
|
||||
);
|
||||
|
||||
const onSubmit = (value: string): void => {
|
||||
setMode("list");
|
||||
submitQuery(value);
|
||||
};
|
||||
|
||||
const browsing = query.trim() === "";
|
||||
const erroredCount = useMemo(
|
||||
() => Object.values(search.perSource).filter((s) => s.error).length,
|
||||
[search.perSource],
|
||||
);
|
||||
const activeCat = CATEGORIES.find((c) => c.key === section);
|
||||
const tabSources = activeCat?.group ? SOURCES.filter((s) => s.group === activeCat.group) : SOURCES;
|
||||
const tabErrored =
|
||||
tabSources.length > 0 && tabSources.every((s) => search.perSource[s.id]?.error);
|
||||
const showStats = useMemo(
|
||||
() => results.some((r) => r.sizeBytes > 0 || r.seeders > 0),
|
||||
[results],
|
||||
);
|
||||
const numW = Math.max(2, String(results.length).length);
|
||||
|
||||
const outageCodes = (sources: readonly Source[]): string => {
|
||||
const codes = [
|
||||
...new Set(sources.map((s) => search.perSource[s.id]?.code).filter(Boolean)),
|
||||
];
|
||||
return codes.length ? ` (${codes.join(", ")})` : "";
|
||||
};
|
||||
|
||||
const status = () => {
|
||||
if (search.loading) {
|
||||
if (results.length > 0)
|
||||
return <Text dimColor>{`searching… ${search.done}/${search.total} sources`}</Text>;
|
||||
return (
|
||||
<Spinner label={`${browsing ? "Loading" : "Searching"} ${search.done}/${search.total} sources`} />
|
||||
);
|
||||
}
|
||||
if (results.length === 0) {
|
||||
if (erroredCount >= search.total) {
|
||||
const downAll = SOURCES.filter((s) => search.perSource[s.id]?.error);
|
||||
return (
|
||||
<Text color={COLOR.warn}>
|
||||
{`Couldn't reach any source. They may be down${outageCodes(downAll)}.`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (tabErrored && activeCat) {
|
||||
const down = tabSources.filter((s) => search.perSource[s.id]?.error);
|
||||
const who = down.length === 1 ? "The source" : `All ${down.length} sources`;
|
||||
return (
|
||||
<Text color={COLOR.warn}>
|
||||
{`Couldn't reach ${activeCat.label}. ${who} may be down${outageCodes(down)}.`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (search.results.length > 0 && activeCat?.group)
|
||||
return <Text dimColor>{`No ${activeCat.label.toLowerCase()} results yet. Try another tab or a search.`}</Text>;
|
||||
return (
|
||||
<Text dimColor>
|
||||
{browsing ? "Nothing new right now." : `No results for "${truncate(query, 28)}".`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const note = erroredCount > 0 ? ` (${erroredCount} source${erroredCount === 1 ? "" : "s"} down)` : "";
|
||||
const head = browsing
|
||||
? "newest across all sources"
|
||||
: `${results.length} result${results.length === 1 ? "" : "s"}`;
|
||||
return <Text dimColor>{head + note}</Text>;
|
||||
};
|
||||
|
||||
const start = windowStart(clamped, results.length, listHeight);
|
||||
const visible = results.slice(start, start + listHeight);
|
||||
const count = results.length > 0 ? `(${results.length})` : undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SearchBar
|
||||
width={contentWidth}
|
||||
value={query}
|
||||
editing={mode === "search"}
|
||||
placeholder={PLACEHOLDER}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Panel
|
||||
title={mode === "detail" ? "details" : browsing ? "latest" : "results"}
|
||||
width={contentWidth}
|
||||
focused={focused}
|
||||
count={mode === "detail" ? undefined : count}
|
||||
height={panelOuter}
|
||||
>
|
||||
{mode === "detail" && detail ? (
|
||||
<Detail r={detail} width={Math.max(10, contentWidth - 4)} />
|
||||
) : (
|
||||
<>
|
||||
<Box>{status()}</Box>
|
||||
<Box flexDirection="column" marginTop={results.length > 0 ? 1 : 0}>
|
||||
{results.length > 0 ? (
|
||||
<Box>
|
||||
<Box width={GUTTER} flexShrink={0} />
|
||||
<Box width={numW} flexShrink={0} justifyContent="flex-end">
|
||||
<Text bold dimColor>#</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0} marginLeft={1}>
|
||||
<Text bold dimColor>Name</Text>
|
||||
</Box>
|
||||
{showStats ? (
|
||||
<>
|
||||
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Size</Text>
|
||||
</Box>
|
||||
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Seed:Lch</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box width={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Added</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Src</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
{visible.map((r, i) => {
|
||||
const index = start + i;
|
||||
const here = index === clamped && focused && mode === "list";
|
||||
const ss = SOURCE_STYLE[r.source];
|
||||
return (
|
||||
<Box key={r.infoHash}>
|
||||
<Box width={GUTTER} flexShrink={0}>
|
||||
<Text color={COLOR.accent}>{here ? ICON.pointer : ""}</Text>
|
||||
</Box>
|
||||
<Box width={numW} flexShrink={0} justifyContent="flex-end">
|
||||
<Text dimColor>{index + 1}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0} marginLeft={1}>
|
||||
<Text
|
||||
wrap="truncate-end"
|
||||
color={here ? COLOR.accent : undefined}
|
||||
dimColor={!here}
|
||||
bold={here}
|
||||
>
|
||||
{cleanText(r.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
{showStats ? (
|
||||
<>
|
||||
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text dimColor>{r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"}</Text>
|
||||
</Box>
|
||||
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={r.seeders > 0 ? COLOR.good : undefined} dimColor={r.seeders === 0}>
|
||||
{r.seeders || r.leechers ? `${r.seeders}:${r.leechers}` : "-"}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box width={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text dimColor>{formatRelative(r.added) || "-"}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={ss.color} dimColor={!here}>
|
||||
{ss.tag}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Text } from "ink";
|
||||
import { RULE } from "../theme";
|
||||
|
||||
export function Rule({ width }: { width: number }) {
|
||||
return <Text color={RULE}>{"─".repeat(Math.max(1, width))}</Text>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { TextField } from "./TextField";
|
||||
import { Panel } from "./Panel";
|
||||
import { COLOR, ICON } from "../theme";
|
||||
|
||||
interface SearchBarProps {
|
||||
width: number;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
editing: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
width,
|
||||
value,
|
||||
placeholder = "Search torrents…",
|
||||
editing,
|
||||
onSubmit,
|
||||
onChange,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<Panel title="search" width={width} focused={editing} height={2}>
|
||||
<Box>
|
||||
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
{editing ? (
|
||||
<TextField
|
||||
defaultValue={value}
|
||||
placeholder={placeholder}
|
||||
onSubmit={onSubmit}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : value ? (
|
||||
<Text wrap="truncate-end">{value}</Text>
|
||||
) : (
|
||||
<Text dimColor>{placeholder}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useStore, useQueueItems, CATEGORIES, type Section } from "../store";
|
||||
import { wrapStep } from "../move";
|
||||
import { ACCENT_RAMP, COLOR, GUTTER, ICON, RULE } from "../theme";
|
||||
|
||||
interface NavItem {
|
||||
key: Section;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const FILTERS: NavItem[] = CATEGORIES.map((c) => ({
|
||||
key: c.key as Section,
|
||||
label: c.label,
|
||||
}));
|
||||
const LIBRARY: NavItem[] = [{ key: "downloads", label: "Downloads" }];
|
||||
|
||||
const GROUPS: NavItem[][] = [FILTERS, LIBRARY];
|
||||
|
||||
const NAV: NavItem[] = GROUPS.flat();
|
||||
|
||||
const BADGE_W = " (00)".length;
|
||||
|
||||
export const RAIL_WIDTH =
|
||||
GUTTER +
|
||||
Math.max(...NAV.map((n) => n.label.length + (n.key === "downloads" ? BADGE_W : 0)));
|
||||
|
||||
export function Sidebar() {
|
||||
const { section, setSection, region, setRegion, queue } = useStore();
|
||||
const focused = region === "sidebar";
|
||||
const idx = Math.max(0, NAV.findIndex((n) => n.key === section));
|
||||
useQueueItems(queue);
|
||||
const active = queue.activeCount;
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) setSection(NAV[wrapStep(idx, -1, NAV.length)]!.key);
|
||||
else if (key.downArrow) setSection(NAV[wrapStep(idx, 1, NAV.length)]!.key);
|
||||
else if (key.return) setRegion("content");
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={RAIL_WIDTH} marginRight={1}>
|
||||
{GROUPS.map((items, gi) => (
|
||||
<Box key={gi} flexDirection="column" marginTop={gi > 0 ? 1 : 0}>
|
||||
{items.map((item) => {
|
||||
const selected = item.key === section;
|
||||
return (
|
||||
<Box key={item.key}>
|
||||
<Box width={GUTTER} flexShrink={0}>
|
||||
{selected ? (
|
||||
<Text color={focused ? ACCENT_RAMP[1] : RULE} bold={focused}>
|
||||
{ICON.bar}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
<Text
|
||||
color={selected ? (focused ? COLOR.accent : COLOR.alt) : undefined}
|
||||
dimColor={!selected}
|
||||
bold={selected && focused}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.key === "downloads" && active > 0 ? (
|
||||
<Box flexShrink={0}>
|
||||
<Text dimColor>{` (${active})`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "ink";
|
||||
import { COLOR } from "../theme";
|
||||
|
||||
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
export function Spinner({ label }: { label?: string }) {
|
||||
const [frame, setFrame] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
||||
timer.unref?.();
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return (
|
||||
<Text>
|
||||
<Text color={COLOR.accent}>{FRAMES[frame]}</Text>
|
||||
{label ? <Text dimColor>{` ${label}`}</Text> : null}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useStore, useQueueItems } from "../store";
|
||||
|
||||
export function TabTitle() {
|
||||
const { queue } = useStore();
|
||||
useQueueItems(queue);
|
||||
const active = queue.activeCount;
|
||||
|
||||
useEffect(() => {
|
||||
const title = active > 0 ? `↓${active} · torlink` : "torlink";
|
||||
process.stdout.write(`\x1b]0;${title}\x07`);
|
||||
if (process.platform === "win32") process.title = title;
|
||||
}, [active]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from "react";
|
||||
import { Text, useInput } from "ink";
|
||||
|
||||
export interface TextFieldProps {
|
||||
isDisabled?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
value: string;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
export function deleteBefore(value: string, cursor: number): Edit {
|
||||
if (cursor === 0) return { value, cursor };
|
||||
return {
|
||||
value: value.slice(0, cursor - 1) + value.slice(cursor),
|
||||
cursor: cursor - 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteWordBefore(value: string, cursor: number): Edit {
|
||||
let i = cursor;
|
||||
while (i > 0 && value[i - 1] === " ") i--;
|
||||
while (i > 0 && value[i - 1] !== " ") i--;
|
||||
return { value: value.slice(0, i) + value.slice(cursor), cursor: i };
|
||||
}
|
||||
|
||||
export function killToEnd(value: string, cursor: number): Edit {
|
||||
return { value: value.slice(0, cursor), cursor };
|
||||
}
|
||||
|
||||
export function insertAt(value: string, cursor: number, text: string): Edit {
|
||||
return {
|
||||
value: value.slice(0, cursor) + text + value.slice(cursor),
|
||||
cursor: cursor + text.length,
|
||||
};
|
||||
}
|
||||
|
||||
const CURSOR = " ";
|
||||
|
||||
export function TextField({
|
||||
isDisabled = false,
|
||||
defaultValue = "",
|
||||
placeholder = "",
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: TextFieldProps) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [cursor, setCursor] = useState(defaultValue.length);
|
||||
|
||||
function apply(next: Edit): void {
|
||||
setValue(next.value);
|
||||
setCursor(Math.max(0, Math.min(next.value.length, next.cursor)));
|
||||
if (next.value !== value) onChange?.(next.value);
|
||||
}
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow || key.downArrow || key.tab || (key.ctrl && input === "c"))
|
||||
return;
|
||||
|
||||
if (key.return) {
|
||||
onSubmit?.(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl) {
|
||||
switch (input) {
|
||||
case "u":
|
||||
apply({ value: "", cursor: 0 });
|
||||
return;
|
||||
case "w":
|
||||
apply(deleteWordBefore(value, cursor));
|
||||
return;
|
||||
case "k":
|
||||
apply(killToEnd(value, cursor));
|
||||
return;
|
||||
case "a":
|
||||
setCursor(0);
|
||||
return;
|
||||
case "e":
|
||||
setCursor(value.length);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.leftArrow) {
|
||||
setCursor(Math.max(0, cursor - 1));
|
||||
return;
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setCursor(Math.min(value.length, cursor + 1));
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
apply(deleteBefore(value, cursor));
|
||||
return;
|
||||
}
|
||||
if (key.meta || !input) return;
|
||||
const text = input.replace(/\x1b?\[<\d+;\d+;\d+[Mm]/g, "");
|
||||
if (!text) return;
|
||||
apply(insertAt(value, cursor, text));
|
||||
},
|
||||
{ isActive: !isDisabled },
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
return value ? <Text>{value}</Text> : <Text dimColor>{placeholder}</Text>;
|
||||
}
|
||||
|
||||
if (value.length === 0) {
|
||||
if (placeholder) {
|
||||
return (
|
||||
<Text>
|
||||
<Text inverse>{placeholder[0]}</Text>
|
||||
<Text dimColor>{placeholder.slice(1)}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <Text inverse>{CURSOR}</Text>;
|
||||
}
|
||||
|
||||
const before = value.slice(0, cursor);
|
||||
const atChar = value[cursor] ?? CURSOR;
|
||||
const after = cursor < value.length ? value.slice(cursor + 1) : "";
|
||||
return (
|
||||
<Text>
|
||||
{before}
|
||||
<Text inverse>{atChar}</Text>
|
||||
{after}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user