getting keybinds going

This commit is contained in:
2026-02-12 17:39:52 -05:00
parent 0bbb327b29
commit 91fcaa9b9e
4 changed files with 177 additions and 90 deletions

View File

@@ -13,7 +13,8 @@ import { useRenderer } from "@opentui/solid";
import type { AuthScreen } from "@/types/auth"; import type { AuthScreen } from "@/types/auth";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import { DIRECTION, LayerGraph, TABS } from "./utils/navigation"; import { DIRECTION, LayerGraph, TABS } from "./utils/navigation";
import { useTheme } from "./context/ThemeContext"; import { useTheme, ThemeProvider } from "./context/ThemeContext";
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
const DEBUG = import.meta.env.DEBUG; const DEBUG = import.meta.env.DEBUG;
@@ -34,6 +35,7 @@ export function App() {
const toast = useToast(); const toast = useToast();
const renderer = useRenderer(); const renderer = useRenderer();
const { theme } = useTheme(); const { theme } = useTheme();
const keybind = useKeybinds();
useMultimediaKeys({ useMultimediaKeys({
playerFocused: () => activeTab() === TABS.PLAYER && layerDepth() > 0, playerFocused: () => activeTab() === TABS.PLAYER && layerDepth() > 0,
@@ -62,66 +64,116 @@ export function App() {
}); });
}); });
// Handle keyboard input with dynamic keybinds
useKeyboard( useKeyboard(
(keyEvent) => { (keyEvent) => {
//handle intra layer navigation const name = keyEvent.name;
if (keyEvent.name == "up" || keyEvent.name) {
// Navigation: up/down
if (keybind.match("up", keyEvent) || keybind.match("down", keyEvent)) {
// TODO: Implement navigation logic
}
// Navigation: left/right
if (keybind.match("left", keyEvent) || keybind.match("right", keyEvent)) {
// TODO: Implement navigation logic
}
// Cycle through options
if (keybind.match("cycle", keyEvent)) {
// TODO: Implement cycle logic
}
// Dive into content
if (keybind.match("dive", keyEvent)) {
// TODO: Implement dive logic
}
// Out of content
if (keybind.match("out", keyEvent)) {
setActiveDepth((prev) => Math.max(0, prev - 1));
return;
}
// Audio controls
if (keybind.match("audio-toggle", keyEvent)) {
audio.togglePlayback();
return;
}
if (keybind.match("audio-next", keyEvent)) {
audio.seekRelative(30); // Skip forward 30 seconds
return;
}
if (keybind.match("audio-prev", keyEvent)) {
audio.seekRelative(-30); // Skip back 30 seconds
return;
}
// Quit application
if (keybind.match("quit", keyEvent)) {
process.exit(0);
} }
}, },
{ release: false }, // Not strictly necessary { release: false },
); );
return ( return (
<ErrorBoundary <KeybindProvider>
fallback={(err) => ( <ThemeProvider mode="dark">
<box border padding={2} borderColor={theme.error}> <ErrorBoundary
<text fg={theme.error}> fallback={(err) => (
Error: {err?.message ?? String(err)} <box border padding={2} borderColor={theme.error}>
{"\n"} <text fg={theme.error}>
Press a number key (1-6) to switch tabs. Error: {err?.message ?? String(err)}
</text> {"\n"}
</box> Press a number key (1-6) to switch tabs.
)} </text>
> </box>
{DEBUG && ( )}
<box flexDirection="row" width="100%" height={1}> >
<text fg={theme.primary}></text> {DEBUG && (
<text fg={theme.secondary}></text> <box flexDirection="row" width="100%" height={1}>
<text fg={theme.accent}></text> <text fg={theme.primary}></text>
<text fg={theme.error}></text> <text fg={theme.secondary}></text>
<text fg={theme.warning}></text> <text fg={theme.accent}></text>
<text fg={theme.success}></text> <text fg={theme.error}></text>
<text fg={theme.info}></text> <text fg={theme.warning}></text>
<text fg={theme.text}></text> <text fg={theme.success}></text>
<text fg={theme.textMuted}></text> <text fg={theme.info}></text>
<text fg={theme.surface}></text> <text fg={theme.text}></text>
<text fg={theme.background}></text> <text fg={theme.textMuted}></text>
<text fg={theme.border}></text> <text fg={theme.surface}></text>
<text fg={theme.borderActive}></text> <text fg={theme.background}></text>
<text fg={theme.diffAdded}></text> <text fg={theme.border}></text>
<text fg={theme.diffRemoved}></text> <text fg={theme.borderActive}></text>
<text fg={theme.diffContext}></text> <text fg={theme.diffAdded}></text>
<text fg={theme.markdownText}></text> <text fg={theme.diffRemoved}></text>
<text fg={theme.markdownHeading}></text> <text fg={theme.diffContext}></text>
<text fg={theme.markdownLink}></text> <text fg={theme.markdownText}></text>
<text fg={theme.markdownCode}></text> <text fg={theme.markdownHeading}></text>
<text fg={theme.syntaxKeyword}></text> <text fg={theme.markdownLink}></text>
<text fg={theme.syntaxString}></text> <text fg={theme.markdownCode}></text>
<text fg={theme.syntaxNumber}></text> <text fg={theme.syntaxKeyword}></text>
<text fg={theme.syntaxFunction}></text> <text fg={theme.syntaxString}></text>
</box> <text fg={theme.syntaxNumber}></text>
)} <text fg={theme.syntaxFunction}></text>
<box flexDirection="row" width="100%" height={1} /> </box>
<box )}
flexDirection="row" <box flexDirection="row" width="100%" height={1} />
width="100%" <box
height="100%" flexDirection="row"
backgroundColor={theme.surface} width="100%"
> height="100%"
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} /> backgroundColor={theme.surface}
{LayerGraph[activeTab()]({ depth: activeDepth })} >
{/**TODO: Contextual controls based on tab/depth**/} <TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
</box> {LayerGraph[activeTab()]({ depth: activeDepth })}
</ErrorBoundary> {/**TODO: Contextual controls based on tab/depth**/}
</box>
</ErrorBoundary>
</ThemeProvider>
</KeybindProvider>
); );
} }

View File

@@ -1,29 +1,28 @@
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import type { JSXElement } from "solid-js"; import { children as solidChildren } from "solid-js";
import type { ParentComponent } from "solid-js";
import type { BoxOptions, TextOptions } from "@opentui/core"; import type { BoxOptions, TextOptions } from "@opentui/core";
export function SelectableBox({ export const SelectableBox: ParentComponent<
selected, {
children, selected: () => boolean;
...props } & BoxOptions
}: { > = (props) => {
selected: () => boolean;
children: JSXElement;
} & BoxOptions) {
const { theme } = useTheme(); const { theme } = useTheme();
const child = solidChildren(() => props.children);
return ( return (
<box <box
border={!!props.border} border={!!props.border}
borderColor={selected() ? theme.surface : theme.border} borderColor={props.selected() ? theme.surface : theme.border}
backgroundColor={selected() ? theme.primary : theme.surface} backgroundColor={props.selected() ? theme.primary : theme.surface}
{...props} {...props}
> >
{children} {child()}
</box> </box>
); );
} };
enum ColorSet { enum ColorSet {
PRIMARY, PRIMARY,
@@ -45,35 +44,31 @@ function getTextColor(set: ColorSet, selected: () => boolean) {
} }
} }
export function SelectableText({ export const SelectableText: ParentComponent<
selected, {
children, selected: () => boolean;
primary, primary?: boolean;
secondary, secondary?: boolean;
tertiary, tertiary?: boolean;
...props } & TextOptions
}: { > = (props) => {
selected: () => boolean; const child = solidChildren(() => props.children);
primary?: boolean;
secondary?: boolean;
tertiary?: boolean;
children: JSXElement;
} & TextOptions) {
return ( return (
<text <text
fg={getTextColor( fg={getTextColor(
primary props.primary
? ColorSet.PRIMARY ? ColorSet.PRIMARY
: secondary : props.secondary
? ColorSet.SECONDARY ? ColorSet.SECONDARY
: tertiary : props.tertiary
? ColorSet.TERTIARY ? ColorSet.TERTIARY
: ColorSet.DEFAULT, : ColorSet.DEFAULT,
selected, props.selected,
)} )}
{...props} {...props}
> >
{children} {child()}
</text> </text>
); );
} };

View File

@@ -5,6 +5,7 @@
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useKeyboard } from "@opentui/solid";
import { format } from "date-fns"; import { format } from "date-fns";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
@@ -18,10 +19,14 @@ enum FeedPaneType {
} }
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
/** Episodes to load per batch */
const ITEMS_PER_BATCH = 50;
export function FeedPage(props: PageProps) { export function FeedPage(props: PageProps) {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0); const [selectedIndex, setSelectedIndex] = createSignal(0);
const [isRefreshing, setIsRefreshing] = createSignal(false); const [isRefreshing, setIsRefreshing] = createSignal(false);
const [loadedEpisodesCount, setLoadedEpisodesCount] = createSignal(ITEMS_PER_BATCH);
const allEpisodes = () => feedStore.getAllEpisodesChronological(); const allEpisodes = () => feedStore.getAllEpisodesChronological();
@@ -29,9 +34,14 @@ export function FeedPage(props: PageProps) {
return format(date, "MMM d, yyyy"); return format(date, "MMM d, yyyy");
}; };
const paginatedEpisodes = () => {
const episodes = allEpisodes();
return episodes.slice(0, loadedEpisodesCount());
};
const episodesByDate = () => { const episodesByDate = () => {
const groups: Record<string, { episode: Episode; feed: Feed }> = {}; const groups: Record<string, { episode: Episode; feed: Feed }> = {};
const sortedEpisodes = allEpisodes(); const sortedEpisodes = paginatedEpisodes();
for (const episode of sortedEpisodes) { for (const episode of sortedEpisodes) {
const dateKey = formatDate(new Date(episode.episode.pubDate)); const dateKey = formatDate(new Date(episode.episode.pubDate));
@@ -54,6 +64,11 @@ export function FeedPage(props: PageProps) {
setIsRefreshing(false); setIsRefreshing(false);
}; };
const handleScrollDown = async () => {
if (feedStore.isLoadingMore() || !feedStore.hasMoreEpisodes()) return;
await feedStore.loadMoreEpisodes();
};
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<box <box
@@ -131,6 +146,12 @@ export function FeedPage(props: PageProps) {
); );
}} }}
</For> </For>
{/* Loading indicator */}
<Show when={feedStore.isLoadingMore()}>
<box padding={1}>
<text fg={theme.textMuted}>Loading more episodes...</text>
</box>
</Show>
</scrollbox> </scrollbox>
</Show> </Show>
</box> </box>

View File

@@ -64,6 +64,19 @@ export function generateSystemTheme(
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha); const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha);
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha); const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha);
// Create darker shades for selected text colors to ensure contrast
const darken = (color: RGBA, factor: number = 0.6) => {
return RGBA.fromInts(
Math.round(color.r * 255 * factor),
Math.round(color.g * 255 * factor),
Math.round(color.b * 255 * factor)
);
};
const selectedPrimary = darken(ansi.cyan, isDark ? 0.4 : 0.6);
const selectedSecondary = darken(ansi.magenta, isDark ? 0.4 : 0.6);
const selectedTertiary = darken(textMuted, isDark ? 0.5 : 0.5);
return { return {
theme: { theme: {
primary: ansi.cyan, primary: ansi.cyan,
@@ -75,6 +88,12 @@ export function generateSystemTheme(
info: ansi.cyan, info: ansi.cyan,
text: fg, text: fg,
textMuted, textMuted,
textPrimary: fg,
textSecondary: textMuted,
textTertiary: textMuted,
textSelectedPrimary: selectedPrimary,
textSelectedSecondary: selectedSecondary,
textSelectedTertiary: selectedTertiary,
selectedListItemText: bg, selectedListItemText: bg,
background: transparent, background: transparent,
backgroundPanel: grays[2], backgroundPanel: grays[2],