navigation controls + starting indicators

This commit is contained in:
2026-02-20 01:13:32 -05:00
parent cc09786592
commit 8d350d9eb5
6 changed files with 128 additions and 107 deletions

View File

@@ -28,7 +28,15 @@ export function App() {
const audio = useAudio();
const toast = useToast();
const renderer = useRenderer();
const { theme } = useTheme();
const themeContext = useTheme();
const theme = themeContext.theme;
// Create a reactive expression for background color
const backgroundColor = () => {
return themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface;
};
const keybind = useKeybinds();
const audioNav = useAudioNavStore();
@@ -62,11 +70,11 @@ export function App() {
useKeyboard(
(keyEvent) => {
const isCycle = keybind.match("cycle", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isDown = keybind.match("down", keyEvent);
const isLeft = keybind.match("left", keyEvent);
const isRight = keybind.match("right", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isDive = keybind.match("dive", keyEvent);
const isOut = keybind.match("out", keyEvent);
const isToggle = keybind.match("audio-toggle", keyEvent);
@@ -75,27 +83,31 @@ export function App() {
const isSeekForward = keybind.match("audio-seek-forward", keyEvent);
const isSeekBackward = keybind.match("audio-seek-backward", keyEvent);
const isQuit = keybind.match("quit", keyEvent);
console.log({
up: isUp,
down: isDown,
left: isLeft,
right: isRight,
cycle: isCycle,
dive: isDive,
out: isOut,
audioToggle: isToggle,
audioNext: isNext,
audioPrev: isPrev,
audioSeekForward: isSeekForward,
audioSeekBackward: isSeekBackward,
quit: isQuit,
});
const isInverting = keybind.isInverting(keyEvent);
// only handling top navigation here, cycle through tabs, just to high priority(player) all else to be handled in each tab
if (nav.activeDepth == 0) {
if (isCycle) {
if (
(isCycle && !isInverting) ||
(isDown && !isInverting) ||
(isUp && isInverting)
) {
nav.nextTab();
return;
}
if (
(isCycle && isInverting) ||
(isDown && isInverting) ||
(isUp && !isInverting)
) {
nav.prevTab();
return;
}
if ((isRight && !isInverting) || (isLeft && isInverting)) {
nav.setActiveDepth(1);
}
}
if (nav.activeDepth == 1) {
}
},
{ release: false },
@@ -117,7 +129,11 @@ export function App() {
flexDirection="column"
width="100%"
height="100%"
backgroundColor={theme.surface}
backgroundColor={
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface
}
>
<LoadingIndicator />
{DEBUG && (
@@ -149,10 +165,7 @@ export function App() {
</box>
)}
<box flexDirection="row" width="100%" height="100%">
<TabNavigation
activeTab={nav.activeTab}
onTabSelect={nav.setActiveTab}
/>
<TabNavigation />
{LayerGraph[nav.activeTab]()}
</box>
</box>

View File

@@ -1,12 +1,8 @@
import { useTheme } from "@/context/ThemeContext";
import { TABS } from "@/utils/navigation";
import { TABS, TabsCount } from "@/utils/navigation";
import { For } from "solid-js";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface TabNavigationProps {
activeTab: TABS;
onTabSelect: (tab: TABS) => void;
}
import { useNavigation } from "@/context/NavigationContext";
export const tabs: TabDefinition[] = [
{ id: TABS.FEED, label: "Feed" },
@@ -17,32 +13,36 @@ export const tabs: TabDefinition[] = [
{ id: TABS.SETTINGS, label: "Settings" },
];
export function TabNavigation(props: TabNavigationProps) {
export function TabNavigation() {
const { theme } = useTheme();
const { activeTab, setActiveTab, activeDepth } = useNavigation();
return (
<box
backgroundColor={theme.surface}
border
borderColor={activeDepth !== 0 ? "transparent" : theme.accent}
backgroundColor={"transparent"}
style={{
flexDirection: "column",
width: 10,
flexGrow: 1,
width: 12,
height: TabsCount * 3 + 2,
}}
>
<For each={tabs}>
{(tab) => (
<SelectableBox
border
selected={() => tab.id == props.activeTab}
onMouseDown={() => props.onTabSelect(tab.id)}
>
<SelectableText
selected={() => tab.id == props.activeTab}
primary
alignSelf="center"
>
{tab.label}
</SelectableText>
</SelectableBox>
<SelectableBox
border
height={3}
selected={() => tab.id == activeTab}
onMouseDown={() => setActiveTab(tab.id)}
>
<SelectableText
selected={() => tab.id == activeTab}
primary
alignSelf="center"
>
{tab.label}
</SelectableText>
</SelectableBox>
)}
</For>
</box>

View File

@@ -6,10 +6,10 @@
"cycle": ["tab"], // this will cycle no matter the depth/orientation
"dive": ["return"],
"out": ["esc"],
"inverse": ["shift"],
"inverseModifier": ["shift"],
"leader": ":", // will not trigger while focused on input
"quit": ["<leader>q"],
"refresh": ["<leader>r"],
"refresh": ["<leader>r"],
"audio-toggle": ["<leader>p"],
"audio-pause": [],
"audio-play": [],

View File

@@ -15,7 +15,7 @@ export type KeybindsResolved = {
cycle: string[]; // this will cycle no matter the depth/orientation
dive: string[];
out: string[];
inverse: string[];
inverseModifier: string;
leader: string; // will not trigger while focused on input
quit: string[];
"audio-toggle": string[];
@@ -57,7 +57,7 @@ export const { use: useKeybinds, provider: KeybindProvider } =
cycle: [],
dive: [],
out: [],
inverse: [],
inverseModifier: "",
leader: "",
quit: [],
refresh: [],
@@ -100,6 +100,18 @@ export const { use: useKeybinds, provider: KeybindProvider } =
return false;
}
function isInverting(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}) {
if (store.inverseModifier === "ctrl" && evt.ctrl) return true;
if (store.inverseModifier === "meta" && evt.meta) return true;
if (store.inverseModifier === "shift" && evt.shift) return true;
return false;
}
// Load on mount
onMount(() => {
load().catch(() => {});
@@ -115,6 +127,7 @@ export const { use: useKeybinds, provider: KeybindProvider } =
save,
print,
match,
isInverting,
};
},
});

View File

@@ -12,6 +12,7 @@ import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { TABS } from "@/utils/navigation";
enum FeedPaneType {
FEED = 1,
@@ -23,27 +24,21 @@ const ITEMS_PER_BATCH = 50;
export function FeedPage() {
const feedStore = useFeedStore();
const [isRefreshing, setIsRefreshing] = createSignal(false);
const [loadedEpisodesCount, setLoadedEpisodesCount] =
createSignal(ITEMS_PER_BATCH);
const nav = useNavigation();
const { theme } = useTheme();
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
string | undefined
>();
const allEpisodes = () => feedStore.getAllEpisodesChronological();
const paginatedEpisodes = () => {
const episodes = allEpisodes();
return episodes.slice(0, loadedEpisodesCount());
};
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const groupEpisodesByDate = () => {
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {};
const episodes = paginatedEpisodes();
for (const item of episodes) {
for (const item of allEpisodes()) {
const dateKey = formatDate(new Date(item.episode.pubDate));
if (!groups[dateKey]) {
groups[dateKey] = [];
@@ -51,14 +46,13 @@ export function FeedPage() {
groups[dateKey].push(item);
}
return Object.entries(groups)
.sort(([a, _aItems], [b, _bItems]) => {
// Convert date strings back to Date objects for proper chronological sorting
const dateA = new Date(a);
const dateB = new Date(b);
// Sort in descending order (newest first)
return dateB.getTime() - dateA.getTime();
});
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => {
// Convert date strings back to Date objects for proper chronological sorting
const dateA = new Date(a);
const dateB = new Date(b);
// Sort in descending order (newest first)
return dateB.getTime() - dateA.getTime();
});
};
const formatDuration = (seconds: number): string => {
@@ -68,7 +62,6 @@ export function FeedPage() {
return `${mins}m`;
};
const { theme } = useTheme();
return (
<box
backgroundColor={theme.background}
@@ -76,11 +69,6 @@ export function FeedPage() {
height="100%"
width="100%"
>
{/* Status line */}
<Show when={isRefreshing()}>
<text fg={theme.warning}>Refreshing feeds...</text>
</Show>
<Show
when={allEpisodes().length > 0}
fallback={
@@ -99,42 +87,49 @@ export function FeedPage() {
{date}
</SelectableText>
<For each={items}>
{(item) => (
<SelectableBox
selected={() => false}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
onMouseDown={() => {
// Selection is handled by App's keyboard navigation
}}
>
<SelectableText selected={() => false} primary>
{item.episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={() => false} primary>
{item.feed.podcast.title}
{(item) => {
const isSelected = () => {
if (
nav.activeTab == TABS.FEED &&
nav.activeDepth == FeedPaneType.FEED &&
selectedEpisodeID() &&
selectedEpisodeID() === item.episode.id
) {
return true;
}
return false;
};
return (
<SelectableBox
selected={isSelected}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
onMouseDown={() => {
// Selection is handled by App's keyboard navigation
}}
>
<SelectableText selected={isSelected} primary>
{item.episode.title}
</SelectableText>
<SelectableText selected={() => false} tertiary>
{formatDuration(item.episode.duration)}
</SelectableText>
</box>
</SelectableBox>
)}
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={isSelected} primary>
{item.feed.podcast.title}
</SelectableText>
<SelectableText selected={isSelected} tertiary>
{formatDuration(item.episode.duration)}
</SelectableText>
</box>
</SelectableBox>
);
}}
</For>
</box>
)}
</For>
{/* Loading indicator */}
<Show when={feedStore.isLoadingMore()}>
<box padding={1}>
<LoadingIndicator />
</box>
</Show>
</scrollbox>
</Show>
</box>

View File

@@ -28,7 +28,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
cycle: ["tab"],
dive: ["return"],
out: ["esc"],
inverse: ["shift"],
inverseModifier: "shift",
leader: ":",
quit: ["<leader>q"],
"audio-toggle": ["<leader>p"],