Initial commit -- Core Cooldown v0.1.0
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
<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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<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;"
|
||||
>
|
||||
<div
|
||||
class="mini-pill flex h-full w-full items-center select-none"
|
||||
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
|
||||
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
|
||||
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' ? '#555' : '#fff'}; {fontStyle}"
|
||||
>
|
||||
{timeText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user