#!/usr/bin/env python3 # $Id: daliclock.py,v 1.7 2026/06/11 21:53:52 jdeifik Exp $ # Copyright Jeff turbo Deifik 2026 & Claude Code, All rights reserved. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ DaliClock - Python reimplementation of the classic X11 daliclock. Uses Signed Distance Field (SDF) morphing: each digit is converted to a distance field, then lerping between two SDFs produces a solid, crisp shape that organically flows from one numeral into the next - exactly like the original xdaliclock. Requires: pip install pillow numpy scipy Controls: + / = Grow larger by 10% - Shrink smaller Mouse wheel Resize Right-click Resize / Quit menu 0 Reset to default size Q / Escape Quit """ import tkinter as tk from PIL import Image, ImageDraw, ImageFont, ImageTk import numpy as np from scipy.ndimage import distance_transform_edt import time import os import sys LINUX = sys.platform.startswith("linux") # --------------------------------------------------------------------------- # Font selection # --------------------------------------------------------------------------- FONT_CANDIDATES = [ "C:/Windows/Fonts/ariblk.ttf", "C:/Windows/Fonts/arialbd.ttf", "C:/Windows/Fonts/verdanab.ttf", "C:/Windows/Fonts/impact.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", ] FONT_PATH = next((f for f in FONT_CANDIDATES if os.path.exists(f)), None) # --------------------------------------------------------------------------- # SDF cache # --------------------------------------------------------------------------- RENDER_SCALE = 4 class SDFCache: def __init__(self): self._sdfs = {} self._fonts = {} def _font(self, size): if size not in self._fonts: if FONT_PATH: self._fonts[size] = ImageFont.truetype(FONT_PATH, size) else: self._fonts[size] = ImageFont.load_default() return self._fonts[size] def get(self, digit, dw, dh): key = (digit, dw, dh) if key not in self._sdfs: self._sdfs[key] = self._compute(str(digit), dw, dh) return self._sdfs[key] def _compute(self, text, dw, dh): sw, sh = dw * RENDER_SCALE, dh * RENDER_SCALE font_sz = int(dh * RENDER_SCALE * 0.88) font = self._font(font_sz) img = Image.new("L", (sw, sh), 0) draw = ImageDraw.Draw(img) bb = draw.textbbox((0, 0), text, font=font) tw, th = bb[2]-bb[0], bb[3]-bb[1] draw.text(((sw-tw)//2 - bb[0], (sh-th)//2 - bb[1]), text, fill=255, font=font) img = img.resize((dw, dh), Image.LANCZOS) mask = np.array(img, dtype=np.float32) > 127 d_in = distance_transform_edt(mask).astype(np.float32) d_out = -distance_transform_edt(~mask).astype(np.float32) sdf = np.where(mask, d_in, d_out) return sdf def clear(self): self._sdfs.clear() SDF = SDFCache() # --------------------------------------------------------------------------- # Color cycling -- matches xdaliclock window.c color_tick_cb # # xdaliclock advances hue by 1.0 degree per tick for fg, # and 0.91 degrees per tick for bg, at 15 ticks/sec (max_cps default=15). # That gives a full fg cycle in 360/15 = 24 seconds. # We run our color update inside the 30 ms render loop (~33 fps) but we # accumulate fractional degree advances so the net rate matches exactly. # # Degrees advanced per render frame (at 33 ms / frame): # fg: 1.0 deg/tick * 15 ticks/sec * 0.033 sec/frame = 0.495 deg/frame # bg: same * 0.91 = 0.450 deg/frame # --------------------------------------------------------------------------- COLOR_CPS = 15.0 # xdaliclock default max_cps FG_DEG_PER_TICK = 1.0 # degrees advanced per color tick BG_DEG_PER_TICK = 0.91 # bg cycles slightly slower FRAME_INTERVAL_SEC = 0.033 # ~30 ms per render frame FG_DEG_PER_FRAME = FG_DEG_PER_TICK * COLOR_CPS * FRAME_INTERVAL_SEC BG_DEG_PER_FRAME = BG_DEG_PER_TICK * COLOR_CPS * FRAME_INTERVAL_SEC # --------------------------------------------------------------------------- # Date auto-display -- matches xdaliclock AUTO_DATE = 67 seconds # # Every 67 seconds the clock briefly shows the date for 3 seconds, # then reverts to time display. # --------------------------------------------------------------------------- AUTO_DATE_INTERVAL = 67.0 # seconds between auto-date flashes AUTO_DATE_DURATION = 3.0 # seconds the date stays visible # --------------------------------------------------------------------------- # Frame renderer # --------------------------------------------------------------------------- MORPH_DURATION = 0.6 AA_WIDTH = 1.2 def hsv_to_rgb_float(h_deg, s, v): """h in degrees [0,360), s and v in [0,1]. Returns (r,g,b) ints 0-255.""" h = (h_deg % 360.0) / 60.0 i = int(h) f = h - i p = v*(1-s); q = v*(1-s*f); t = v*(1-s*(1-f)) r, g, b = [(v,t,p),(q,v,p),(p,v,t),(p,q,v),(t,p,v),(v,p,q)][i % 6] return int(r*255), int(g*255), int(b*255) def sdf_to_alpha(sdf): return np.clip((sdf + AA_WIDTH) / (2.0 * AA_WIDTH), 0.0, 1.0) def render_frame(current_digits, target_digits, morph_t, fg, bg, dw, dh, cw, pad): total_w = 6*dw + 2*cw + 2*pad total_h = dh + 2*pad fg_arr = np.array(fg, dtype=np.float32) bg_arr = np.array(bg, dtype=np.float32) out = np.empty((total_h, total_w, 3), dtype=np.float32) out[:] = bg_arr def ox_of(d): cb = (1 if d >= 2 else 0) + (1 if d >= 4 else 0) return pad + d*dw + cb*cw for d in range(6): ox = ox_of(d) oy = pad t = morph_t[d] te = t*t*(3 - 2*t) if te <= 0.0: sdf = SDF.get(current_digits[d], dw, dh) elif te >= 1.0: sdf = SDF.get(target_digits[d], dw, dh) else: sdf_src = SDF.get(current_digits[d], dw, dh) sdf_dst = SDF.get(target_digits[d], dw, dh) sdf = sdf_src*(1.0 - te) + sdf_dst*te alpha = sdf_to_alpha(sdf)[:, :, np.newaxis] region = out[oy:oy+dh, ox:ox+dw] region[:] = bg_arr*(1.0 - alpha) + fg_arr*alpha img = Image.fromarray(out.astype(np.uint8)) draw = ImageDraw.Draw(img) r = max(4, int(dh * 0.055)) fg_t = tuple(fg) for d_idx in [2, 4]: cx = ox_of(d_idx) - cw//2 for cy in [int(pad + dh*0.33), int(pad + dh*0.67)]: draw.ellipse([cx-r, cy-r, cx+r, cy+r], fill=fg_t) return img # --------------------------------------------------------------------------- # Layout constants # --------------------------------------------------------------------------- # These are the fixed LOGICAL pixel dimensions used for SDF generation and # rendering. They never change -- they define the "1x" reference frame. BASE_DIGIT_W = 120 BASE_DIGIT_H = 200 BASE_COLON_W = 36 BASE_PADDING = 24 DEFAULT_SCALE = 0.5 # start at 50% of full size MIN_SCALE = 0.10 MAX_SCALE = 4.0 SCALE_STEP = 0.03 # Fine-grained sizing strategy # ───────────────────────────── # The SDF cache and render_frame() always work at the fixed BASE_* logical # dimensions above, so the SDF cache never needs to be cleared on resize. # # In _resize_canvas() / _tick() we compute the desired display pixel size as: # display_w = round(BASE_TOTAL_W * scale) # display_h = round(BASE_TOTAL_H * scale) # # The rendered PIL image (at BASE resolution) is then rescaled with # Image.LANCZOS to exactly (display_w, display_h) before being handed to # Tkinter. Because PIL can resize to ANY integer pixel size, even a tiny # SCALE_STEP like 0.01 always produces a visibly different window -- it is # no longer limited by rounding of individual digit/colon/padding components. BASE_TOTAL_W = 6*BASE_DIGIT_W + 2*BASE_COLON_W + 2*BASE_PADDING # 888 px BASE_TOTAL_H = BASE_DIGIT_H + 2*BASE_PADDING # 248 px # --------------------------------------------------------------------------- # Linux title-bar removal via python-xlib (Motif WM hints) # --------------------------------------------------------------------------- def _try_remove_titlebar_xlib(wid): """ Set _MOTIF_WM_HINTS on the window to suppress decorations. Requires python-xlib: pip install python-xlib Returns True on success, False if python-xlib is not available. """ try: from Xlib import display as Xdisplay, Xatom from Xlib.xobject import drawable dpy = Xdisplay.Display() win = dpy.create_resource_object("window", wid) atom = dpy.intern_atom("_MOTIF_WM_HINTS", False) # flags=2 (decorations field valid), decorations=0 (none) win.change_property(atom, atom, 32, [2, 0, 0, 0, 0]) dpy.sync() dpy.close() return True except Exception: return False def _linux_make_borderless(root): """ Best-effort removal of window decorations on Linux/X11. Strategy (tried in order): 1. python-xlib - sets _MOTIF_WM_HINTS directly; works on GNOME, KDE, XFCE and any EWMH-compliant compositor. 2. xprop shell - same hint via the xprop utility (no extra Python dep). 3. -type hint - ask the WM via the Extended WM Hints window-type atom. "dock" and "splash" are typically undecorated. In all cases the window remains WM-managed so focus and stacking work. """ root.update_idletasks() wid = root.winfo_id() # --- attempt 1: python-xlib --- if _try_remove_titlebar_xlib(wid): return # --- attempt 2: xprop --- try: import subprocess result = subprocess.run( [ "xprop", "-id", str(wid), "-f", "_MOTIF_WM_HINTS", "32c", "-set", "_MOTIF_WM_HINTS", "0x2, 0x0, 0x0, 0x0, 0x0", ], capture_output=True, timeout=2, ) if result.returncode == 0: return except Exception: pass # --- attempt 3: wm_attributes window type --- for wtype in ("dock", "splash", "toolbar"): try: root.wm_attributes("-type", wtype) return except Exception: continue # If all else fails the window will have a title bar but everything # else (focus, stacking, resize, drag) will still work correctly. # --------------------------------------------------------------------------- # Clock # --------------------------------------------------------------------------- class DaliClock: def __init__(self, root): self.root = root self.scale = DEFAULT_SCALE if LINUX: # Do NOT use overrideredirect on Linux. It bypasses the WM # entirely, causing two problems that cannot be fixed in userspace: # * The window floats permanently above all other windows. # * Keyboard events are never delivered (WM skips focus handoff). # We keep the window WM-managed and remove decorations separately. root.wm_attributes("-type", "normal") # Schedule decoration removal after the window is mapped so the # WM has had a chance to create its frame. root.after(150, lambda: _linux_make_borderless(root)) else: root.overrideredirect(True) root.resizable(False, False) root.configure(bg="#000000") now = time.localtime() self.current_digits = self._time_digits(now) self.target_digits = list(self.current_digits) self.morph_start = [0.0] * 6 self.morph_t = [0.0] * 6 # --- color state (hue in degrees, saturation, value) --- # Start at blue fg / cyan bg, roughly matching xdaliclock defaults self._fg_hue = 240.0 # degrees self._bg_hue = 180.0 # degrees (complementary-ish) self._fg_sat = 1.0 self._fg_val = 1.0 self._bg_sat = 1.0 self._bg_val = 0.55 # --- date auto-display state --- self._showing_date = False self._next_date_flip = time.time() + AUTO_DATE_INTERVAL self._photo = None self.canvas = tk.Canvas(root, highlightthickness=0, bg="#000000") self.canvas.pack() self._img_id = self.canvas.create_image(0, 0, anchor="nw") self._bind_keys() self._bind_drag() self._bind_context_menu() self._initial_layout = True # center window only on first layout self._resize_canvas() self._tick() # ---- sizing ------------------------------------------------------------ # Rendering always happens at the fixed BASE logical resolution. # Scaling is applied afterwards by PIL when resizing the final image. @property def dw(self): return BASE_DIGIT_W @property def dh(self): return BASE_DIGIT_H @property def cw(self): return BASE_COLON_W @property def pad(self): return BASE_PADDING @property def display_w(self): return max(1, round(BASE_TOTAL_W * self.scale)) @property def display_h(self): return max(1, round(BASE_TOTAL_H * self.scale)) # ---- setup ------------------------------------------------------------- def _bind_keys(self): r = self.root r.bind("", lambda e: self._resize(+SCALE_STEP)) r.bind("", lambda e: self._resize(+SCALE_STEP)) r.bind("", lambda e: self._resize(+SCALE_STEP)) r.bind("", lambda e: self._resize(-SCALE_STEP)) r.bind("", lambda e: self._resize(-SCALE_STEP)) r.bind("", lambda e: self._set_scale(DEFAULT_SCALE)) r.bind("", lambda e: self._set_scale(DEFAULT_SCALE)) r.bind("", self._wheel) r.bind("", lambda e: r.destroy()) r.bind("", lambda e: r.destroy()) self.canvas.bind("", self._wheel) # Linux/X11: scroll wheel arrives as Button-4 (up) / Button-5 (down) if LINUX: for widget in (r, self.canvas): widget.bind("", lambda e: self._resize(+SCALE_STEP)) widget.bind("", lambda e: self._resize(-SCALE_STEP)) def _bind_drag(self): """Drag the window by left-click-dragging anywhere on the canvas.""" self._drag_x = 0 self._drag_y = 0 self.canvas.bind("", self._drag_start) self.canvas.bind("", self._drag_move) def _drag_start(self, e): self._drag_x = e.x_root - self.root.winfo_x() self._drag_y = e.y_root - self.root.winfo_y() def _drag_move(self, e): self.root.geometry(f"+{e.x_root - self._drag_x}+{e.y_root - self._drag_y}") def _bind_context_menu(self): """Right-click popup - reliable resize that needs no keyboard focus.""" self._menu = tk.Menu(self.root, tearoff=0) self._menu.add_command(label="Larger (+)", command=lambda: self._resize(+SCALE_STEP)) self._menu.add_command(label="Smaller (-)", command=lambda: self._resize(-SCALE_STEP)) self._menu.add_command(label="Reset size", command=lambda: self._set_scale(DEFAULT_SCALE)) self._menu.add_separator() self._menu.add_command(label="Quit", command=self.root.destroy) self.canvas.bind("", self._show_menu) def _show_menu(self, e): try: self._menu.tk_popup(e.x_root, e.y_root) finally: self._menu.grab_release() def _resize_canvas(self): w = self.display_w h = self.display_h self.canvas.configure(width=w, height=h) self.root.update_idletasks() if self._initial_layout: # Center on screen at startup only; subsequent resizes keep position. sw = self.root.winfo_screenwidth() sh = self.root.winfo_screenheight() rw = self.root.winfo_width() rh = self.root.winfo_height() self.root.geometry(f"+{(sw-rw)//2}+{(sh-rh)//2}") self._initial_layout = False # ---- resize ------------------------------------------------------------ def _wheel(self, e): self._resize(+SCALE_STEP if e.delta > 0 else -SCALE_STEP) def _resize(self, delta): self._set_scale(self.scale + delta) def _set_scale(self, s): s = max(MIN_SCALE, min(MAX_SCALE, round(s, 10))) if abs(s - self.scale) < 1e-9: return self.scale = s # No SDF.clear() needed: SDFs are always at fixed BASE dimensions. self._resize_canvas() # ---- color cycling (matches xdaliclock: 1 deg/tick, 15 ticks/sec) ---- def _advance_colors(self): self._fg_hue = (self._fg_hue + FG_DEG_PER_FRAME) % 360.0 self._bg_hue = (self._bg_hue + BG_DEG_PER_FRAME) % 360.0 def _current_colors(self): fg = hsv_to_rgb_float(self._fg_hue, self._fg_sat, self._fg_val) bg = hsv_to_rgb_float(self._bg_hue, self._bg_sat, self._bg_val) return fg, bg # ---- date auto-display (matches xdaliclock AUTO_DATE = 67 s) --------- def _update_date_mode(self, now): if now >= self._next_date_flip: if not self._showing_date: # Switch to date display for AUTO_DATE_DURATION seconds self._showing_date = True self._next_date_flip = now + AUTO_DATE_DURATION else: # Switch back to time; schedule next date flash self._showing_date = False self._next_date_flip = now + AUTO_DATE_INTERVAL # ---- time/date digits -------------------------------------------------- def _time_digits(self, t=None): if t is None: t = time.localtime() h1, h2 = divmod(t.tm_hour, 10) m1, m2 = divmod(t.tm_min, 10) s1, s2 = divmod(t.tm_sec, 10) return [h1, h2, m1, m2, s1, s2] def _date_digits(self, t=None): """Return 6 digits as MM/DD/YY (same order as xdaliclock default MMDDYY).""" if t is None: t = time.localtime() mo1, mo2 = divmod(t.tm_mon, 10) d1, d2 = divmod(t.tm_mday, 10) y1, y2 = divmod(t.tm_year % 100, 10) return [mo1, mo2, d1, d2, y1, y2] # ---- animation --------------------------------------------------------- def _tick(self): now = time.time() loc = time.localtime(now) # --- update date/time mode --- self._update_date_mode(now) newt = self._date_digits(loc) if self._showing_date else self._time_digits(loc) # --- advance colors every frame (xdaliclock rate) --- self._advance_colors() fg, bg = self._current_colors() # --- morph state --- for d in range(6): if newt[d] != self.target_digits[d]: if self.morph_t[d] > 0.9: self.current_digits[d] = self.target_digits[d] self.target_digits[d] = newt[d] self.morph_start[d] = now self.morph_t[d] = 0.0 if self.target_digits[d] != self.current_digits[d]: elapsed = now - self.morph_start[d] self.morph_t[d] = min(elapsed / MORPH_DURATION, 1.0) if self.morph_t[d] >= 1.0: self.current_digits[d] = self.target_digits[d] self.morph_t[d] = 0.0 img = render_frame( self.current_digits, self.target_digits, self.morph_t, fg, bg, self.dw, self.dh, self.cw, self.pad ) # Rescale the fixed-resolution render to the current display size. # PIL LANCZOS handles any fractional scale -- even a 0.01 step always # produces a different (display_w, display_h) without touching the SDF. dw, dh = self.display_w, self.display_h if img.size != (dw, dh): img = img.resize((dw, dh), Image.LANCZOS) bg_hex = "#%02x%02x%02x" % bg self.root.configure(bg=bg_hex) self.canvas.configure(bg=bg_hex) self._photo = ImageTk.PhotoImage(img) self.canvas.itemconfigure(self._img_id, image=self._photo) self.root.after(33, self._tick) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- if __name__ == "__main__": print(f"Font: {FONT_PATH or 'Pillow default (no TTF found)'}") print("Pre-computing digit SDFs...") root = tk.Tk() app = DaliClock(root) root.mainloop()