#!/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()