feat: tabbed About dialog with keyboard shortcuts reference

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 02:29:31 -04:00
parent ac7cb70cb7
commit 507e3b79b7
2 changed files with 318 additions and 51 deletions

View File

@@ -4,12 +4,43 @@
let { onClose }: { onClose: () => void } = $props(); let { onClose }: { onClose: () => void } = $props();
let version = $state(''); let version = $state('');
let activeTab = $state<'about' | 'shortcuts'>('about');
$effect(() => { $effect(() => {
getVersion().then((v) => { getVersion().then((v) => {
version = v; version = v;
}); });
}); });
const globalShortcuts = [
{ key: 'Space', action: 'Play / Pause' },
{ key: 'K', action: 'Play / Pause' },
{ key: 'J', action: 'Shuttle slower (−0.25×)' },
{ key: 'L', action: 'Shuttle faster (+0.25×)' },
{ key: '←', action: 'Seek back 5s' },
{ key: '→', action: 'Seek forward 5s' },
{ key: 'Shift+←', action: 'Seek back 1s' },
{ key: 'Shift+→', action: 'Seek forward 1s' },
{ key: ',', action: 'Previous frame' },
{ key: '.', action: 'Next frame' },
{ key: '< (Shift+,)', action: 'Previous keyframe' },
{ key: '> (Shift+.)', action: 'Next keyframe' },
{ key: 'C / P', action: 'Toggle mode' },
{ key: '⌘/', action: 'About' },
];
const playerShortcuts = [
{ key: 'F', action: 'Toggle fullscreen' },
{ key: 'X', action: 'Cut clip at playhead → Clipper' },
{ key: 'Z', action: 'Cut clip 10s before playhead → Clipper' },
];
const clipperShortcuts = [
{ key: 'I', action: 'Set in-point' },
{ key: 'O', action: 'Set out-point' },
{ key: 'Delete / ⌫', action: 'Remove selected clip' },
{ key: '⌘E', action: 'Export' },
];
</script> </script>
<div <div
@@ -26,11 +57,35 @@
> >
<div <div
class="dialog" class="dialog"
class:wide={activeTab === 'shortcuts'}
role="dialog" role="dialog"
tabindex="-1" tabindex="-1"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
> >
<!-- Tab bar -->
<div class="tab-bar" role="tablist">
<button
class="tab"
class:active={activeTab === 'about'}
role="tab"
aria-selected={activeTab === 'about'}
onclick={() => { activeTab = 'about'; }}
type="button"
>About</button>
<button
class="tab"
class:active={activeTab === 'shortcuts'}
role="tab"
aria-selected={activeTab === 'shortcuts'}
onclick={() => { activeTab = 'shortcuts'; }}
type="button"
>Shortcuts</button>
</div>
{#if activeTab === 'about'}
<!-- About content -->
<div class="about-content">
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"> <svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290"> <linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
@@ -82,6 +137,54 @@
> >
code.cottongin.xyz/cottongin/gui-video-clipper code.cottongin.xyz/cottongin/gui-video-clipper
</a> </a>
</div>
{:else}
<!-- Shortcuts content -->
<div class="shortcuts-content">
<section>
<h3>Global</h3>
<table>
<tbody>
{#each globalShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section>
<h3>Player Mode</h3>
<table>
<tbody>
{#each playerShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section>
<h3>Clipper Mode</h3>
<table>
<tbody>
{#each clipperShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
{/if}
<div class="actions"> <div class="actions">
<button class="primary" onclick={onClose}>Close</button> <button class="primary" onclick={onClose}>Close</button>
</div> </div>
@@ -103,7 +206,7 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 32px; padding: 24px 32px 32px;
max-width: 360px; max-width: 360px;
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -111,6 +214,53 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
transition: max-width 0.2s ease;
}
.dialog.wide {
max-width: 520px;
max-height: 70vh;
}
/* --- Tabs --- */
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
width: 100%;
margin-bottom: 12px;
}
.tab {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
padding: 8px 0;
cursor: pointer;
border-radius: 0;
}
.tab:hover {
color: var(--text-primary);
background: none;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: none;
}
/* --- About content --- */
.about-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
} }
.app-icon { .app-icon {
@@ -153,6 +303,64 @@
text-decoration: underline; text-decoration: underline;
} }
/* --- Shortcuts content --- */
.shortcuts-content {
width: 100%;
text-align: left;
overflow-y: auto;
max-height: calc(70vh - 140px);
display: flex;
flex-direction: column;
gap: 16px;
}
.shortcuts-content h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin: 0 0 6px;
}
.shortcuts-content table {
width: 100%;
border-collapse: collapse;
}
.shortcuts-content tr {
border-bottom: 1px solid var(--border);
}
.shortcuts-content tr:last-child {
border-bottom: none;
}
.key-cell {
width: 40%;
padding: 5px 8px 5px 0;
white-space: nowrap;
}
.action-cell {
padding: 5px 0;
color: var(--text-secondary);
font-size: 13px;
}
kbd {
display: inline-block;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
line-height: 1.4;
}
/* --- Actions --- */
.actions { .actions {
margin-top: 12px; margin-top: 12px;
} }

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import AboutDialog from '$lib/components/AboutDialog.svelte';
vi.mock('@tauri-apps/api/app', () => ({
getVersion: vi.fn().mockResolvedValue('0.2.1'),
}));
describe('AboutDialog', () => {
it('renders the About tab by default', () => {
const { getByText } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
expect(getByText('GUI Video Clipper')).toBeTruthy();
expect(getByText('by cottongin')).toBeTruthy();
});
it('shows two tabs: About and Shortcuts', () => {
const { getByRole } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
const tablist = getByRole('tablist');
expect(tablist).toBeTruthy();
expect(tablist.querySelectorAll('[role="tab"]')).toHaveLength(2);
});
it('switches to Shortcuts tab on click', async () => {
const { getByText, getAllByText } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
const shortcutsTab = getByText('Shortcuts');
await fireEvent.click(shortcutsTab);
expect(getAllByText('Play / Pause').length).toBeGreaterThan(0);
expect(getByText('Toggle mode')).toBeTruthy();
expect(getByText('Player Mode')).toBeTruthy();
expect(getByText('Clipper Mode')).toBeTruthy();
});
it('switches back to About tab', async () => {
const { getByText, getByRole } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
await fireEvent.click(getByRole('tab', { name: 'Shortcuts' }));
await fireEvent.click(getByRole('tab', { name: 'About' }));
expect(getByText('GUI Video Clipper')).toBeTruthy();
expect(getByText('by cottongin')).toBeTruthy();
});
it('calls onClose when Close button is clicked', async () => {
const onClose = vi.fn();
const { getByText } = render(AboutDialog, {
props: { onClose },
});
await fireEvent.click(getByText('Close'));
expect(onClose).toHaveBeenCalled();
});
});