352 lines
11 KiB
Svelte
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>
|