diff --git a/config.php b/config.php index c6b0ec6..8bb0179 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ J registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: 'T', label: 'tag quick-pick', handler: () => openTagQuickPick() }); registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: 'C', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } }); registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: 'M', label: 'mail (plugin)' }); +registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: 'W', label: 'watch', handler: () => window._watchToggle?.() }); registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '/', label: 'search', handler: () => openSearch() }); registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '⇥ / ⇧ ⇥', label: 'cycle buckets', handler: (e) => { const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')]; diff --git a/public/plugins/watch/faces/simple/digits.svg b/public/plugins/watch/faces/simple/digits.svg new file mode 100644 index 0000000..79a695a --- /dev/null +++ b/public/plugins/watch/faces/simple/digits.svg @@ -0,0 +1,21 @@ + diff --git a/public/plugins/watch/faces/simple/hour.svg b/public/plugins/watch/faces/simple/hour.svg new file mode 100644 index 0000000..ca1254e --- /dev/null +++ b/public/plugins/watch/faces/simple/hour.svg @@ -0,0 +1,20 @@ + diff --git a/public/plugins/watch/faces/simple/minute.svg b/public/plugins/watch/faces/simple/minute.svg new file mode 100644 index 0000000..61bcbe6 --- /dev/null +++ b/public/plugins/watch/faces/simple/minute.svg @@ -0,0 +1,20 @@ + diff --git a/public/plugins/watch/faces/simple/second.svg b/public/plugins/watch/faces/simple/second.svg new file mode 100644 index 0000000..f57aa7e --- /dev/null +++ b/public/plugins/watch/faces/simple/second.svg @@ -0,0 +1,25 @@ + diff --git a/public/plugins/watch/plugin.json b/public/plugins/watch/plugin.json new file mode 100644 index 0000000..7752270 --- /dev/null +++ b/public/plugins/watch/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "watch", + "version": "1.0.0", + "description": "Analog clock overlay. Shows after 5 min idle or press W.", + "frontend": { + "js": ["watch.js"], + "css": ["watch.css"], + "html": ["watch.html"] + } +} diff --git a/public/plugins/watch/watch.css b/public/plugins/watch/watch.css new file mode 100644 index 0000000..5a0bce7 --- /dev/null +++ b/public/plugins/watch/watch.css @@ -0,0 +1,93 @@ +/* ── Watch overlay ── */ + +#watch-overlay { + position: fixed; inset: 0; z-index: 9000; + display: grid; place-items: center; + cursor: pointer; overflow: hidden; + opacity: 0; transform: scale(0.985); + transition: opacity 220ms ease, transform 220ms ease; + pointer-events: none; +} +#watch-overlay.visible { + opacity: 1; transform: scale(1); + pointer-events: auto; +} +#watch-overlay.hidden { display: none; } + +#watch-darkening { + position: absolute; inset: 0; + background: #000; opacity: 0.8; + pointer-events: none; + backdrop-filter: blur(var(--ui-blur-overlay, 0px)); + -webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px)); +} + +#watch-analog { + position: relative; z-index: 1; + width: min(78vmin, 720px); + aspect-ratio: 1; +} +#watch-dial { + position: relative; width: 100%; height: 100%; + border-radius: 50%; +} +.watch-layer { + position: absolute; inset: 0; + transform-origin: 50% 50%; + will-change: transform; +} +.watch-layer svg { display: block; width: 100%; height: 100%; } + +#watch-layer-digits { z-index: 1; filter: drop-shadow(0 0 12px rgba(0,0,0,0.8)); } +#watch-layer-hour { z-index: 3; } +#watch-layer-minute { z-index: 2; } +#watch-layer-second { z-index: 4; } + +#watch-second-wobble { + width: 100%; height: 100%; + transform-origin: 50% 50%; + transition: transform 92ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform; +} + +/* Dark theme (default): white hands/markers */ +#watch-overlay { + --watch-svg-markers-fill: #fff; + --watch-svg-hour-fill: var(--t-accent, #68c6ed); + --watch-svg-minute-fill: #fff; + --watch-svg-second-fill: var(--t-accent, #68c6ed); +} + +/* Light theme: black hands/markers */ +@media (prefers-color-scheme: light) { + #watch-overlay { + --watch-svg-markers-fill: #111; + --watch-svg-hour-fill: var(--t-accent, #2563eb); + --watch-svg-minute-fill: #111; + --watch-svg-second-fill: var(--t-accent, #2563eb); + } + #watch-darkening { background: #fff; opacity: 0.85; } + #watch-layer-digits { filter: drop-shadow(0 0 12px rgba(255,255,255,0.6)); } +} + +/* Also detect hyprcrd's own theme mode via body class / data attribute */ +body.light-theme #watch-overlay, +[data-theme-mode="light"] #watch-overlay { + --watch-svg-markers-fill: #111; + --watch-svg-hour-fill: var(--t-accent, #2563eb); + --watch-svg-minute-fill: #111; + --watch-svg-second-fill: var(--t-accent, #2563eb); +} +body.light-theme #watch-darkening, +[data-theme-mode="light"] #watch-darkening { + background: #fff; opacity: 0.85; +} +body.light-theme #watch-layer-digits, +[data-theme-mode="light"] #watch-layer-digits { + filter: drop-shadow(0 0 12px rgba(255,255,255,0.6)); +} + +@media (prefers-reduced-motion: reduce) { + #watch-overlay { transition: none !important; } + #watch-second-wobble { transition: none !important; } +} diff --git a/public/plugins/watch/watch.html b/public/plugins/watch/watch.html new file mode 100644 index 0000000..d4c882d --- /dev/null +++ b/public/plugins/watch/watch.html @@ -0,0 +1,13 @@ +
diff --git a/public/plugins/watch/watch.js b/public/plugins/watch/watch.js new file mode 100644 index 0000000..e11d62f --- /dev/null +++ b/public/plugins/watch/watch.js @@ -0,0 +1,169 @@ +// ── Watch plugin — analog clock overlay ── + +const $ = id => document.getElementById(id); + +const LAYER_MAP = { + digits: 'watch-layer-digits', + hour: 'watch-layer-hour', + minute: 'watch-layer-minute', + second: 'watch-second-wobble', +}; + +let visible = false; +let tickTimer = null; +let idleTimer = null; +let lastActivity = Date.now(); +let showedAt = 0; +let wobbleKick = null, wobbleRebound = null; +let svgCache = {}; +let loaded = false; + +const IDLE_MINUTES = 5; + +// ── SVG loading ── + +async function loadSvg(layer) { + if (svgCache[layer]) return svgCache[layer]; + try { + const res = await fetch(`plugins/watch/faces/simple/${layer}.svg`); + if (!res.ok) return ''; + let svg = await res.text(); + svg = svg.replace(/<\?xml[\s\S]*?\?>/gi, '').replace(//gi, '').trim(); + svgCache[layer] = svg; + return svg; + } catch { return ''; } +} + +async function loadFace() { + if (loaded) return; + for (const [layer, elId] of Object.entries(LAYER_MAP)) { + const el = $(elId); + if (el) el.innerHTML = await loadSvg(layer); + } + loaded = true; +} + +// ── Clock logic ── + +function tick() { + const now = new Date(); + const h12 = now.getHours() % 12; + const m = now.getMinutes(); + const s = now.getSeconds(); + + const hourEl = $('watch-layer-hour'); + const minEl = $('watch-layer-minute'); + const secEl = $('watch-layer-second'); + + if (hourEl) hourEl.style.transform = `rotate(${(((h12 + m / 60) / 12) * 360).toFixed(3)}deg)`; + if (minEl) minEl.style.transform = `rotate(${(((m + s / 60) / 60) * 360).toFixed(3)}deg)`; + if (secEl) secEl.style.transform = `rotate(${((s / 60) * 360).toFixed(3)}deg)`; + + triggerWobble(); +} + +function triggerWobble() { + const wobEl = $('watch-second-wobble'); + if (!wobEl) return; + clearTimeout(wobbleKick); + clearTimeout(wobbleRebound); + wobEl.style.transform = 'rotate(0deg)'; + requestAnimationFrame(() => { + wobEl.style.transform = 'rotate(0.78deg)'; + wobbleKick = setTimeout(() => { wobEl.style.transform = 'rotate(-0.25deg)'; }, 95); + wobbleRebound = setTimeout(() => { wobEl.style.transform = 'rotate(0deg)'; }, 205); + }); +} + +function startTick() { + if (tickTimer) return; + tick(); + tickTimer = setInterval(tick, 1000); +} + +function stopTick() { + if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } + clearTimeout(wobbleKick); + clearTimeout(wobbleRebound); +} + +// ── Show / hide ── + +async function show() { + if (visible) return; + visible = true; + const overlay = $('watch-overlay'); + if (!overlay) return; + showedAt = Date.now(); + await loadFace(); + overlay.classList.remove('hidden'); + startTick(); + requestAnimationFrame(() => overlay.classList.add('visible')); +} + +function hide() { + if (!visible) return; + visible = false; + const overlay = $('watch-overlay'); + if (!overlay) return; + overlay.classList.remove('visible'); + stopTick(); + setTimeout(() => { if (!visible) overlay.classList.add('hidden'); }, 250); +} + +function toggle() { visible ? hide() : show(); } + +// ── Idle auto-show ── + +function resetIdleTimer() { + clearTimeout(idleTimer); + if (!IDLE_MINUTES) return; + idleTimer = setTimeout(() => { + if (visible) return; + if (document.visibilityState !== 'visible') return; + if (document.querySelector('.overlay.open')) return; + show(); + }, IDLE_MINUTES * 60000); +} + +function onActivity() { + const now = Date.now(); + if (now - lastActivity < 300) return; + lastActivity = now; + if (visible && now - showedAt > 500) hide(); + resetIdleTimer(); +} + +// ── Init ── + +function init() { + const overlay = $('watch-overlay'); + if (!overlay) return; + + overlay.addEventListener('click', () => { hide(); onActivity(); }); + + window.addEventListener('keydown', e => { + if (e.key === 'Escape' && visible) { + e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); + hide(); onActivity(); + } + }, { capture: true }); + + for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) { + window.addEventListener(ev, onActivity, { passive: true }); + } + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') onActivity(); + }); + + resetIdleTimer(); + window.hyprcrd?.registerPlugin({ name: 'watch', version: '1.0.0' }); +} + +window._watchToggle = toggle; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +}