Files
core-cooldown/src/lib/components/MiniTimer.svelte
T

352 lines
11 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import type { TimerSnapshot } from "../stores/timer";
import { config, loadConfig } from "../stores/config";
const appWindow = getCurrentWebviewWindow();
let timeText = $state("--:--");
let state = $state<"running" | "paused" | "breakActive">("paused");
let progress = $state(0);
let accentColor = $state("#ff4d00");
let breakColor = $state("#7c6aef");
let countdownFont = $state("");
let draggable = $state(false);
let pomodoroEnabled = $state(false);
let pomodoroCyclePosition = $state(0);
let pomodoroTotalInCycle = $state(4);
// Use config store directly for live updates
const uiZoom = $derived($config.ui_zoom);
function formatTime(secs: number): string {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
// Sync local color/font state from the config store
function applyConfigLocals() {
accentColor = $config.accent_color || "#ff4d00";
breakColor = $config.break_color || "#7c6aef";
countdownFont = $config.countdown_font || "";
}
onMount(async () => {
// Get initial state
try {
const snap = await invoke<TimerSnapshot>("get_timer_state");
updateFromSnapshot(snap);
} catch (e) {
console.error("Mini: Failed to get state", e);
}
// Load config into the shared config store
await loadConfig();
applyConfigLocals();
// Live-reload config when main window changes settings (zoom, colors, etc.)
await listen("config-changed", async () => {
await loadConfig();
applyConfigLocals();
});
// Listen for ticks
await listen<TimerSnapshot>("timer-tick", (event) => {
updateFromSnapshot(event.payload);
});
// Save position on move (debounced)
let posTimer: ReturnType<typeof setTimeout>;
appWindow.onMoved(() => {
clearTimeout(posTimer);
posTimer = setTimeout(async () => {
try {
const pos = await appWindow.outerPosition();
await invoke("save_window_position", {
label: "mini", x: pos.x, y: pos.y, width: 184, height: 92,
});
} catch {}
}, 500);
});
});
// Click-through mode: reactive to config changes
$effect(() => {
const isClickThrough = $config.mini_click_through;
const threshold = $config.mini_hover_threshold;
if (!isClickThrough) {
appWindow.setIgnoreCursorEvents(false);
draggable = true;
return;
}
appWindow.setIgnoreCursorEvents(true);
draggable = false;
let hoverTime = 0;
let localDraggable = false;
const POLL_MS = 200;
const interval = setInterval(async () => {
try {
const [cx, cy] = await invoke<[number, number]>("get_cursor_position");
const pos = await appWindow.outerPosition();
const size = await appWindow.outerSize();
const inside =
cx >= pos.x && cx <= pos.x + size.width &&
cy >= pos.y && cy <= pos.y + size.height;
if (inside) {
hoverTime += POLL_MS / 1000;
if (hoverTime >= threshold && !localDraggable) {
localDraggable = true;
draggable = true;
await appWindow.setIgnoreCursorEvents(false);
}
} else {
if (localDraggable) {
localDraggable = false;
draggable = false;
await appWindow.setIgnoreCursorEvents(true);
}
hoverTime = 0;
}
} catch {}
}, POLL_MS);
return () => clearInterval(interval);
});
function updateFromSnapshot(snap: TimerSnapshot) {
state = snap.state;
if (snap.state === "breakActive") {
timeText = formatTime(snap.breakTimeRemaining);
progress = snap.breakTotalDuration > 0
? snap.breakTimeRemaining / snap.breakTotalDuration
: 0;
} else {
timeText = formatTime(snap.timeRemaining);
progress = snap.progress;
}
pomodoroEnabled = snap.pomodoroEnabled;
pomodoroCyclePosition = snap.pomodoroCyclePosition;
pomodoroTotalInCycle = snap.pomodoroTotalInCycle;
}
// Click opens main window
async function openMain() {
try {
await invoke("set_view", { view: "dashboard" });
} catch {}
}
// Drag support
function startDrag(e: MouseEvent) {
if (e.button === 0) {
appWindow.startDragging();
}
}
// Ring SVG computations
const ringSize = 34;
const strokeW = 3;
const pad = 16;
const viewSize = ringSize + pad * 2;
const radius = (ringSize - strokeW) / 2;
const circumference = 2 * Math.PI * radius;
const dashOffset = $derived(circumference * (1 - progress));
const ctr = viewSize / 2;
const activeColor = $derived(state === "breakActive" ? breakColor : accentColor);
function lighten(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lr = Math.min(255, r + (255 - r) * amount);
const lg = Math.min(255, g + (255 - g) * amount);
const lb = Math.min(255, b + (255 - b) * amount);
return `#${Math.round(lr).toString(16).padStart(2, "0")}${Math.round(lg).toString(16).padStart(2, "0")}${Math.round(lb).toString(16).padStart(2, "0")}`;
}
const gradA = $derived(activeColor);
const gradB = $derived(lighten(activeColor, 0.25));
const fontStyle = $derived(
countdownFont ? `font-family: '${countdownFont}', monospace;` : ""
);
const zoomScale = $derived(uiZoom / 100);
// Base window dimensions (matches lib.rs toggle_mini_window)
const MINI_BASE_W = 184;
const MINI_BASE_H = 92;
// Resize the actual Tauri window when zoom changes
$effect(() => {
const scale = uiZoom / 100;
const w = Math.round(MINI_BASE_W * scale);
const h = Math.round(MINI_BASE_H * scale);
appWindow.setSize(new LogicalSize(w, h));
});
</script>
<div class="w-full h-full flex items-center justify-center overflow-hidden" role="status" aria-label="Mini timer: {timeText} {state === 'breakActive' ? 'break active' : state === 'running' ? 'running' : 'paused'}">
<div
style="
width: {100 / zoomScale}%;
height: {100 / zoomScale}%;
transform: scale({zoomScale});
transform-origin: center center;
"
>
<div
class="flex items-center justify-center w-full h-full"
style="padding: 22px 14px 22px 24px;"
>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="mini-pill flex h-full w-full items-center select-none"
role="application"
class:mini-draggable={draggable}
style="
background: rgba(0, 0, 0, 0.85);
border-radius: 9999px;
border: 1px solid {draggable ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'};
backdrop-filter: blur(12px);
cursor: {draggable ? 'grab' : 'default'};
padding: 0 12px 0 5px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
{draggable ? `box-shadow: 0 0 12px rgba(255, 255, 255, 0.08);` : ''}
"
onmousedown={draggable ? startDrag : undefined}
ondblclick={draggable ? openMain : undefined}
>
<!-- Mini ring with glow -->
<div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;">
<!-- Glow SVG (larger for blur room) -->
<svg
aria-hidden="true"
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
style="left: {-pad}px; top: {-pad}px; transform: rotate(-90deg); overflow: visible;"
>
<defs>
<linearGradient id="mini-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color={gradA} />
<stop offset="100%" stop-color={gradB} />
</linearGradient>
<filter id="mini-glow-wide" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="8" />
</filter>
<filter id="mini-glow-mid" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="4" />
</filter>
<filter id="mini-glow-core" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{#if progress > 0.002}
<!-- Layer 1: Wide ambient bloom -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW * 4}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-wide)"
opacity="0.35"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 2: Medium glow -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW * 2}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-mid)"
opacity="0.5"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 3: Core tight glow -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-core)"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Non-glow SVG: track + crisp ring -->
<svg
aria-hidden="true"
width={ringSize}
height={ringSize}
class="absolute"
style="transform: rotate(-90deg);"
>
<!-- Background track -->
<circle
cx={ringSize / 2} cy={ringSize / 2} r={radius}
fill="none"
stroke={state === "paused" ? "#1a1a1a" : "#161616"}
stroke-width={strokeW}
/>
{#if progress > 0.002}
<!-- Foreground arc -->
<circle
cx={ringSize / 2} cy={ringSize / 2} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
</div>
<!-- Countdown text -->
<span
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
style="color: {state === 'paused' ? '#a8a8a8' : '#fff'}; {fontStyle}"
>
{timeText}
</span>
<!-- F3: Pomodoro cycle indicator -->
{#if pomodoroEnabled}
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #a8a8a8;">
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
</span>
{/if}
</div>
</div>
</div>
</div>