Files
2026-10-09 18:53:28 +01:00

305 lines
13 KiB
Python

"""
Themes: a palette, and a drawing style for the pad.
Most themes only change colours. Two change how the pad is drawn:
Blueprint an engineering drawing: white linework on cyanotype blue, a
faint grid, unknown keys as dashed hidden lines, chain lines
through the knob centres, and a title block in the corner
Sketch pencil on paper: lines that wander a little and are gone over
twice, as a hand would, in a handwriting font
Nord and Catppuccin use their published palettes, both MIT licensed.
"""
from dataclasses import dataclass, field, replace
from pathlib import Path
@dataclass(frozen=True)
class Metrics:
"""
The pad's proportions, in board units. The view scales the whole board
to fit, so only the ratios between these matter.
Knobs North's values come from its Figma file: each one is the design's
measurement divided by the design's 291.67 unit key and multiplied by
our 96, so the proportions are exactly the designer's.
"""
key: float = 96.0 # a key tile, square
gap_x: float = 16.0 # between key columns
gap_y: float = 16.0 # between key rows
margin: float = 30.0 # board edge to anything
key_radius: float = 5.0
r_out: float = 54.0 # outer reach of a knob's arcs
r_in: float = 40.0
r_knob: float = 30.0 # the knob body
row_h: float = 22.0 # one legend row
row_gap: float = 12.0 # knob bottom to first legend row
row_sep: float = 0.0 # hairline between rows, 0 for none
dial_w: float = 160.0 # legend block width
legend_radius: float = 3.0
rule_x: float = 0.0 # vertical rule inside the legend block
label_x: float = 22.0 # where a legend's text starts
byte_pt: float = 10.0 # action byte type size
label_pt: float = 13.0 # key legend type size
row_pt: float = 12.0 # knob legend type size
row_byte_pt: float = 10.0
badge: float = 13.0 # the changed / unknown corner flag
# Where a key's legend sits, as fractions of the tile: left, top, right,
# bottom. Empty keeps the fixed insets that suit a 96 unit key.
label_band: tuple = ()
byte_inset: tuple = () # same, for the action byte
@dataclass(frozen=True)
class Theme:
key: str
name: str
dark: bool
# palette, as hex
window: str # behind everything
board: str # the pad's face
ink: str # lines and legends
ink_dim: str # secondary text, unknown outlines
hatch: str # hatching, grid, quiet lines
accent: str # selection and unwritten changes
accent_text: str # text on an accent filled button
good: str
bad: str
line: str # widget borders
field: str # text boxes
# drawing style
unknown: str = "hatch" # hatch, or "hidden": dashed outline
grid: bool = False # a faint drawing grid behind the pad
title_block: bool = False # sheet border and title block
centre_lines: bool = False # chain lines through knob centres
wobble: float = 0.0 # how far a pencil line wanders, board units
caps: bool = False # legends in capitals, like drawing lettering
font: str = "" # legend font; empty means the system font
mono_legends: bool = False # draw legends in the monospace font
badge: str = "tape" # pending marker: "tape" strip or "star"
legend_box: bool = False # a border around each knob's three rows
query_badge: bool = False # a "?" in the corner of an unknown key
byte_prefix: bool = False # knob rows read 0x13 rather than 13
badge_glyph: str = "" # a character stamped in the corner flag
byte_ink: str = "" # action byte grey; empty means ink_dim
well: str = "" # gradient's far colour; empty means flat
glow: float = 0.0 # neon bloom on accent strokes, board units
knob_art: str = "" # "figma" draws the outline from vectors.py
button_radius: int = 4 # window buttons and fields
pill_buttons: bool = False # footer buttons outlined rather than bare
outline_primary: bool = False # the write button outlined, not filled
ui_pt: int = 0 # base type size; 0 keeps Qt's own
title_pt: int = 18 # the inspector's heading
mono_status: bool = False # footer status in the mono face
metrics: Metrics = field(default_factory=Metrics)
ui_font: str = "" # window chrome; empty means the system font
mono_font: str = "" # window chrome, monospace parts
notes: tuple = field(default=())
SILKSCREEN = Theme(
"silkscreen", "Silkscreen", True,
window="#17191b", board="#202326", ink="#d8d4cb", ink_dim="#72767b",
hatch="#34383c", accent="#eb8a2f", accent_text="#1b1206",
good="#8db36b", bad="#e0584f", line="#3a3e42", field="#121416")
WHITE_BOARD = Theme(
"white-board", "White board", False,
window="#e4e2dc", board="#f7f6f2", ink="#232323", ink_dim="#86847e",
hatch="#d6d2c8", accent="#cf6a17", accent_text="#ffffff",
good="#3f7d20", bad="#c0392b", line="#cbc7bd", field="#ffffff")
BLUEPRINT = Theme(
"blueprint", "Blueprint", True,
window="#123b6d", board="#123b6d", ink="#f2f5f8", ink_dim="#93b3d8",
hatch="#285a92", accent="#ffd166", accent_text="#0b2545",
good="#a8e6a1", bad="#ff9a8f", line="#3d6aa3", field="#0d2e57",
unknown="hidden", grid=True, title_block=True, centre_lines=True,
caps=True, mono_legends=True)
SKETCH = Theme(
"sketch", "Sketch", False,
window="#f3efe6", board="#f3efe6", ink="#2e2b28", ink_dim="#8c857a",
hatch="#b9b1a3", accent="#c8553d", accent_text="#ffffff",
good="#4f7a36", bad="#b03a2e", line="#cfc6b6", field="#fbf9f4",
wobble=1.6, font="Architects Daughter")
NORD = Theme(
"nord", "Nord", True,
window="#2e3440", board="#3b4252", ink="#eceff4", ink_dim="#7b88a1",
hatch="#434c5e", accent="#88c0d0", accent_text="#2e3440",
good="#a3be8c", bad="#bf616a", line="#4c566a", field="#292e39")
MOCHA = Theme(
"catppuccin-mocha", "Catppuccin Mocha", True,
window="#181825", board="#1e1e2e", ink="#cdd6f4", ink_dim="#7f849c",
hatch="#313244", accent="#cba6f7", accent_text="#1e1e2e",
good="#a6e3a1", bad="#f38ba8", line="#45475a", field="#11111b")
LATTE = Theme(
"catppuccin-latte", "Catppuccin Latte", False,
window="#e6e9ef", board="#eff1f5", ink="#4c4f69", ink_dim="#8c8fa1",
hatch="#ccd0da", accent="#8839ef", accent_text="#eff1f5",
good="#40a02b", bad="#d20f39", line="#bcc0cc", field="#dce0e8")
# Designed in Figma by a member of the community. The palette is Silkscreen's
# own window and board with a hotter orange and neutral rather than warm greys,
# so what makes it its own theme is mostly the drawing switches below.
# :D -Oaken
_F = 96.0 / 291.67 # Scale factor from the Figma file: its key tile is 291.67 units, ours is 96.
KNOBS_NORTH = Theme(
"knobs-north", "Knobs North", True,
window="#17191b", board="#202326", ink="#ffffff", ink_dim="#b5b5b5",
hatch="#2f2f2f", accent="#ff7700", accent_text="#ffffff",
good="#87df9a", bad="#e0584f", line="#6e6e6e", field="#202326",
badge="star", badge_glyph="*", legend_box=True, query_badge=True,
byte_prefix=True, byte_ink="#505050",
# tile and knob gradients, both #202326 falling to #131619 in the file
well="#131619", glow=35 * _F, knob_art="figma", # "glow" value here was set way too low, making it not visible. Fixed. -Oaken
font="Harmattan", mono_font="JetBrains Mono NL", ui_font="Harmattan",
button_radius=7, pill_buttons=True, outline_primary=True, ui_pt=18, # ui_pt=15 was a little small, bumping slightly to 18pt. -Oaken
title_pt=28, mono_status=True,
metrics=Metrics(
key=96.0,
gap_x=88.0 * _F, gap_y=60.58 * _F, margin=74.0 * _F,
key_radius=20.0 * _F,
r_out=128.58 * _F, r_in=110.0 * _F, r_knob=92.5 * _F,
row_h=75.0 * _F, row_gap=40.0 * _F, row_sep=2.0 * _F,
dial_w=512.0 * _F, legend_radius=15.0 * _F,
rule_x=73.0 * _F, label_x=94.0 * _F,
byte_pt=24.0 * _F, label_pt=55.0 * _F,
row_pt=40.0 * _F, row_byte_pt=20.0 * _F,
badge=64.29 * _F,
# The design sets line height to exactly the type size; Qt's is
# looser, so the band is taller than the design's 100/300 while
# staying centred on the same line.
# Values changed to allow 2 lines to render per key with increased font size. Elipsis will show on second line instead of a third line. -Oaken
label_band=(38 / 300, 0.10, 37 / 300, 0.01), # label_band=(38 / 300, 0.18, 37 / 300, 0.18) <-- old
byte_inset=(19 / 300, 10 / 300)),
notes=("Designed in Figma by a member of the community.",
"Knobs along the top. North. Or east if you rotate...")) # Small tweak -Oaken
# --------------------------------------------------------------- palettes
#
# A palette is colour and nothing else. Anything that decides how the pad is
# drawn - proportions, badges, knob art, typefaces - belongs to the style.
PALETTE_FIELDS = ("window", "board", "ink", "ink_dim", "hatch", "accent",
"accent_text", "good", "bad", "line", "field", "well",
"byte_ink", "dark")
def _palette(theme, **over):
"""Pull a palette out of a theme written the old way."""
out = {f: getattr(theme, f) for f in PALETTE_FIELDS}
out.update(over)
return out
PALETTES = {
"graphite": ("Graphite", _palette(SILKSCREEN)),
"paper": ("Paper", _palette(WHITE_BOARD)),
"nord": ("Nord", _palette(NORD)),
"mocha": ("Catppuccin Mocha", _palette(MOCHA)),
"latte": ("Catppuccin Latte", _palette(LATTE)),
"neon": ("Neon", _palette(KNOBS_NORTH)),
"blueprint": ("Blueprint", _palette(BLUEPRINT)),
"graph": ("Graph paper", _palette(SKETCH)),
}
# ----------------------------------------------------------------- styles
#
# recolour lists the palettes a style is offered with. Blueprint and Sketch
# are left alone: a blueprint that isn't blue, or a pencil sketch that isn't
# on paper, is not the same idea.
OPEN = ("graphite", "paper", "nord", "mocha", "latte", "neon")
STYLES = {
"silkscreen": ("Silkscreen", SILKSCREEN, OPEN),
"knobs-north": ("Knobs North", KNOBS_NORTH, OPEN),
"blueprint": ("Blueprint", BLUEPRINT, ("blueprint",)),
"sketch": ("Hand drawn", SKETCH, ("graph",)),
}
# Keys as they were before styles and palettes were separated, so a saved
# setting from an older build still finds its theme.
LEGACY = {
"silkscreen": ("silkscreen", "graphite"),
"white-board": ("silkscreen", "paper"),
"nord": ("silkscreen", "nord"),
"catppuccin-mocha": ("silkscreen", "mocha"),
"catppuccin-latte": ("silkscreen", "latte"),
"blueprint": ("blueprint", "blueprint"),
"sketch": ("sketch", "graph"),
"knobs-north": ("knobs-north", "neon"),
}
_LEGACY_BY_PAIR = {v: k for k, v in LEGACY.items()}
def compose(style_key, palette_key):
"""One style wearing one palette."""
style_name, base, _allowed = STYLES[style_key]
palette_name, colours = PALETTES[palette_key]
key = _LEGACY_BY_PAIR.get((style_key, palette_key),
f"{style_key}/{palette_key}")
name = (style_name if len(STYLES[style_key][2]) == 1
else f"{style_name} / {palette_name}")
return replace(base, key=key, name=name, **colours)
THEMES = {}
for _s, (_sn, _base, _allowed) in STYLES.items():
for _p in _allowed:
_t = compose(_s, _p)
THEMES[_t.key] = _t
DEFAULT = "silkscreen"
def parts(key):
"""(style, palette) for a theme key, old style or new."""
if key in LEGACY:
return LEGACY[key]
if "/" in key:
s, p = key.split("/", 1)
if s in STYLES and p in PALETTES:
return s, p
return LEGACY[DEFAULT]
FONTS = Path(__file__).resolve().parent / "fonts"
def get(key):
if key in THEMES:
return THEMES[key]
style, palette = parts(key or DEFAULT)
if palette not in STYLES[style][2]:
palette = STYLES[style][2][0]
return THEMES[compose(style, palette).key]
def mix(a, b, t):
"""Blend two hex colours; t = 0 gives a, 1 gives b."""
a, b = a.lstrip("#"), b.lstrip("#")
ca = [int(a[i:i + 2], 16) for i in (0, 2, 4)]
cb = [int(b[i:i + 2], 16) for i in (0, 2, 4)]
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(ca, cb))
def load_fonts():
"""Register the fonts shipped with the app. Safe to call more than once."""
from PySide6.QtGui import QFontDatabase
from . import padview
for f in sorted(FONTS.glob("*.ttf")):
QFontDatabase.addApplicationFont(str(f))
padview._FAMILIES = None