From e9a9a94fefcac32daecc154b63f3f5571799fbb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:34:34 +0000 Subject: [PATCH 1/4] Move the interface to Aseprite's metrics and Catppuccin's colours The references are Aseprite themed with Catppuccin - the file in them is called catppuccin.ase - so this is two separable things, and worth naming which is which. The metrics are measured off Aseprite: a 12px menu bar, an 11px tab, a 17px tool options row, 15px tool slots in an 18px column, an 80px timeline, a 15px status bar, all at 1x with the interface magnified twice on screen. The colours are Catppuccin, which is a theme someone put on Aseprite rather than Aseprite's own. They arrived as lossy WebP: thirty thousand colours in a screenshot of an interface that uses about thirty. Nothing can be recovered from that the way Picotron's palette was recovered, because the damage is not a function - the same true colour comes back differently depending on what surrounds it. It does not have to be recovered, only identified. Catppuccin is published, and five of its values survive the compression at distance nought - #1e1e2e, #eff1f5, #dce0e8, #fe640b, #a6e3a1 - with the rest landing within 1 to 3. What was uncertain was which palette it is, not what the palette contains. The framebuffer target is a parameter now rather than 270. Aseprite's chrome fills 960x540 at 1x and the references magnify it twice; Picotron's filled 480x270. Hard-coding either renders the other at half or double its intended density, and both themes are kept. The control icons are redrawn at 16x16 with two tones. Aseprite's are not flat silhouettes - the pencil has a lit body and a dark tip - and the second tone is half alpha in the same sheet, so tinting multiplies it down to a shade and one sheet still serves every theme and state. Four of them were redrawn twice: the first bucket read as a diamond and the first eraser as a second pencil, which only rendering the sheet showed. The linter now refuses a raw hex in a theme that claims a palette. Forty hand-picked colours that only nearly agree is most of what "messy and inconsistent" meant, and a colour two off a real entry looks fine alone and wrong beside everything else. Both listed exceptions are measured facts: Aseprite paints its transparency checker itself, in the same two greys, under both the light and the dark reference. 121 assertions pass, the linter is clean, and Picotron's eight pixel tiles still match - that theme stays first-class and verified, it is just no longer the default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM --- chisel/support/catppuccin.gs | 51 ++++++ chisel/support/logical-size.gs | 25 +-- chisel/themes/aseprite-latte.gs | 88 ++++++++++ chisel/themes/aseprite-mocha.gs | 93 +++++++++++ chisel/themes/theme-named.gs | 11 +- main.gs | 16 +- playground.gs | 12 +- resources/icons.png | Bin 490 -> 1281 bytes shot.gs | 10 +- studio/sprite/editor.gs | 2 +- studio/studio.gs | 2 +- tests/core.gs | 30 ++-- tools/catppuccin.py | 92 +++++++++++ tools/lint.py | 32 ++++ tools/make-icons.py | 274 ++++++++++++++++++++++++++------ 15 files changed, 645 insertions(+), 93 deletions(-) create mode 100644 chisel/support/catppuccin.gs create mode 100644 chisel/themes/aseprite-latte.gs create mode 100644 chisel/themes/aseprite-mocha.gs create mode 100644 tools/catppuccin.py diff --git a/chisel/support/catppuccin.gs b/chisel/support/catppuccin.gs new file mode 100644 index 0000000..3505c14 --- /dev/null +++ b/chisel/support/catppuccin.gs @@ -0,0 +1,51 @@ +// Catppuccin, exactly as published. +// +// The Aseprite references are themed with it - the file in them is even called +// catppuccin.ase - and they arrived as lossy WebP: thirty thousand colours in a +// screenshot of an interface that uses about thirty. Nothing can be measured +// out of that the way Picotron's palette was measured, because the damage is +// not a function: the same true colour comes back differently depending on what +// surrounds it. +// +// It does not have to be measured. Catppuccin's values are published, and five +// of them survive the compression at distance nought - #1e1e2e, #eff1f5, +// #dce0e8, #fe640b, #a6e3a1 - with the rest landing within 1 to 3. That is not +// a guess that happens to fit; it is a known answer read off a damaged copy, +// and the identification is what was uncertain rather than the values. +// +// Both flavours carry the same twenty-six names, which is the point of them: a +// theme written against the names swaps light for dark by swapping the map. +function catppuccinMocha() { + return { + rosewater: '#f5e0dc', flamingo: '#f2cdcd', pink: '#f5c2e7', + mauve: '#cba6f7', red: '#f38ba8', maroon: '#eba0ac', + peach: '#fab387', yellow: '#f9e2af', green: '#a6e3a1', + teal: '#94e2d5', sky: '#89dceb', sapphire: '#74c7ec', + blue: '#89b4fa', lavender: '#b4befe', + + // Six greys from the text down to the darkest ground. Every surface in the + // interface is one of these, which is why the whole thing holds together. + text: '#cdd6f4', subtext1: '#bac2de', subtext0: '#a6adc8', + overlay2: '#9399b2', overlay1: '#7f849c', overlay0: '#6c7086', + surface2: '#585b70', surface1: '#45475a', surface0: '#313244', + base: '#1e1e2e', mantle: '#181825', crust: '#11111b' + } +} + +function catppuccinLatte() { + return { + rosewater: '#dc8a78', flamingo: '#dd7878', pink: '#ea76cb', + mauve: '#8839ef', red: '#d20f39', maroon: '#e64553', + peach: '#fe640b', yellow: '#df8e1d', green: '#40a02b', + teal: '#179299', sky: '#04a5e5', sapphire: '#209fb5', + blue: '#1e66f5', lavender: '#7287fd', + + // Latte runs the greys the other way: `text` is the darkest and `crust` the + // lightest, so a theme written against the names inverts correctly without + // knowing which flavour it has. + text: '#4c4f69', subtext1: '#5c5f77', subtext0: '#6c6f85', + overlay2: '#7c7f93', overlay1: '#8c8fa1', overlay0: '#9ca0b0', + surface2: '#acb0be', surface1: '#bcc0cc', surface0: '#ccd0da', + base: '#eff1f5', mantle: '#e6e9ef', crust: '#dce0e8' + } +} diff --git a/chisel/support/logical-size.gs b/chisel/support/logical-size.gs index b93e488..fe2f0f6 100644 --- a/chisel/support/logical-size.gs +++ b/chisel/support/logical-size.gs @@ -2,13 +2,20 @@ import "ghost:math" // The logical framebuffer to draw into, given a real window. // -// Picotron renders 480x270 and magnifies the whole frame, which is where its -// 12px rows and 7x7 icons come from - those numbers only mean anything if a -// drawn pixel is several screen pixels wide. Studio keeps that pixel density -// but not the cap: it picks the integer magnification that puts the logical -// height nearest 270, then divides the real window by it. A 1440x900 window -// becomes 480x300 at 3x rather than 480x270 letterboxed, so a wider monitor -// buys workspace instead of margins. +// An interface built on a pixel grid only reads correctly if a drawn pixel +// covers several screen pixels: a 12px menu bar and a 16px icon are chunky at +// 2x and microscopic at 1x. So Studio draws into a small framebuffer and lets +// the engine magnify the whole frame. +// +// The magnification is picked rather than fixed. `target` is the logical height +// the design was drawn for, and this returns the integer magnification putting +// the window nearest it, then divides the window by that. A 1440x900 window at +// the Aseprite target becomes 720x450 at 2x rather than 960x540 letterboxed, so +// a wider monitor buys workspace instead of margins. +// +// 540 is Aseprite's, measured: its chrome at 1x fills 960x540 and the reference +// screenshots magnify that twice to 1920x1080. Picotron's was 270. Passing the +// target rather than hard-coding it is what lets one framebuffer serve both. // // That difference matters because Studio is an editor inside a window, not an // operating system. At a hard 480x270 a 32x32 sprite plus a palette, timeline @@ -20,11 +27,11 @@ import "ghost:math" // wrong - the one rule this cannot bend. // // `preferred` forces a magnification when the user has chosen one; null picks. -function logicalSize(width, height, preferred = null) { +function logicalSize(width, height, preferred = null, target = 540) { scale = preferred if (scale == null) { - scale = math.floor((height / 270.0) + 0.5) + scale = math.floor((height / (target * 1.0)) + 0.5) } scale = math.floor(scale) diff --git a/chisel/themes/aseprite-latte.gs b/chisel/themes/aseprite-latte.gs new file mode 100644 index 0000000..2841204 --- /dev/null +++ b/chisel/themes/aseprite-latte.gs @@ -0,0 +1,88 @@ +import "lumen:color" +import { Theme } from "chisel/theme" +import { catppuccinLatte } from "chisel/support/catppuccin" + +// The same interface in Catppuccin Latte. +// +// Every token below is identical to the Mocha theme's - the same names in the +// same order - because Catppuccin's two flavours share their vocabulary and +// run their greys in opposite directions. `text` is the darkest grey here and +// the lightest there, so "text on base" is legible in both without a single +// conditional. +// +// That is the whole argument for naming colours by role rather than by +// appearance, and it is why this file is a palette swap rather than a rewrite. +function asepriteLatte() { + palette = catppuccinLatte() + + theme = new Theme('aseprite.latte').set({ + // The workspace is `base`; every bar that sits on it is `surface0`. That + // one step is what separates chrome from canvas in this design, and it is + // the whole of the separation - Aseprite does not outline its bars either. + 'window.face': color.hex(palette.base), + 'panel.face': color.hex(palette.surface0), + 'panel.well': color.hex(palette.mantle), + 'field.face': color.hex(palette.mantle), + + 'outline': color.hex(palette.mantle), + 'bevel.light': color.hex(palette.surface1), + 'bevel.dark': color.hex(palette.crust), + + // A tool button has no fill until something happens to it, then it climbs + // the surface ramp. Measured: the selected pencil sits on surface2 while + // its unselected neighbours sit on the bar itself. + 'button.face': color.hex(palette.surface0), + 'button.hover': color.hex(palette.surface1), + 'button.pressed': color.hex(palette.surface2), + 'button.selected': color.hex(palette.surface2), + + 'text.normal': color.hex(palette.text), + 'text.dim': color.hex(palette.overlay1), + 'text.selected': color.hex(palette.text), + + 'accent': color.hex(palette.blue), + 'focus': color.hex(palette.sapphire), + + 'good': color.hex(palette.green), + 'warn': color.hex(palette.yellow), + 'bad': color.hex(palette.red), + + 'title.face': color.hex(palette.surface1), + 'title.text': color.hex(palette.text), + + 'scrim': color.rgb(76, 79, 105, 0.4), + + // Aseprite's own, not Catppuccin's. Both references show the same two + // greys under two completely different themes, which is how you can tell + // the checker is painted by the application rather than the theme. + 'checker.light': color.hex('#c0c0c0'), + 'checker.dark': color.hex('#808080') + }).sized({ + unit: 4, + gutter: 2, + pad: 4, + + row: 12, + bar: 12, + tab: 11, + tool: 15, + icon: 16, + swatch: 10, + check: 10, + scroll: 8, + checker: 16, + + // Aseprite rounds a corner by a single pixel where it rounds one at all. + radius: 2, + cut: 1, + + capTop: 2, + cap: 6, + baseline: 7, + descender: 9 + }) + + theme.native = 12 + + return theme +} diff --git a/chisel/themes/aseprite-mocha.gs b/chisel/themes/aseprite-mocha.gs new file mode 100644 index 0000000..bfd7c3b --- /dev/null +++ b/chisel/themes/aseprite-mocha.gs @@ -0,0 +1,93 @@ +import "lumen:color" +import { Theme } from "chisel/theme" +import { catppuccinMocha } from "chisel/support/catppuccin" + +// Aseprite's layout and metrics, wearing Catppuccin Mocha. +// +// Two separable things, and worth saying which is which. The *metrics* below +// are measured off Aseprite itself - a 12px menu bar, an 11px tab, a 17px tool +// options row, 15px tool slots in an 18px column, an 80px timeline and a 15px +// status bar, all at 1x with the interface magnified 2x on screen. The +// *colours* are Catppuccin, which is a theme someone put on Aseprite rather +// than Aseprite's own; the reference screenshots are of a file called +// catppuccin.ase and every large flat area in them lands on a published +// Catppuccin value. +// +// The transparency checkerboard is the exception that proves it: #c0c0c0 over +// #808080 in both the light and the dark reference, unchanged by the theme, +// because Aseprite paints the checker itself rather than letting a theme near +// it. +function asepriteMocha() { + palette = catppuccinMocha() + + theme = new Theme('aseprite.mocha').set({ + // The workspace is `base`; every bar that sits on it is `surface0`. That + // one step is what separates chrome from canvas in this design, and it is + // the whole of the separation - Aseprite does not outline its bars either. + 'window.face': color.hex(palette.base), + 'panel.face': color.hex(palette.surface0), + 'panel.well': color.hex(palette.mantle), + 'field.face': color.hex(palette.mantle), + + 'outline': color.hex(palette.mantle), + 'bevel.light': color.hex(palette.surface1), + 'bevel.dark': color.hex(palette.crust), + + // A tool button has no fill until something happens to it, then it climbs + // the surface ramp. Measured: the selected pencil sits on surface2 while + // its unselected neighbours sit on the bar itself. + 'button.face': color.hex(palette.surface0), + 'button.hover': color.hex(palette.surface1), + 'button.pressed': color.hex(palette.surface2), + 'button.selected': color.hex(palette.surface2), + + 'text.normal': color.hex(palette.text), + 'text.dim': color.hex(palette.overlay1), + 'text.selected': color.hex(palette.text), + + 'accent': color.hex(palette.blue), + 'focus': color.hex(palette.sapphire), + + 'good': color.hex(palette.green), + 'warn': color.hex(palette.yellow), + 'bad': color.hex(palette.red), + + 'title.face': color.hex(palette.surface1), + 'title.text': color.hex(palette.text), + + 'scrim': color.rgb(17, 17, 27, 0.6), + + // Aseprite's own, not Catppuccin's. Both references show the same two + // greys under two completely different themes, which is how you can tell + // the checker is painted by the application rather than the theme. + 'checker.light': color.hex('#c0c0c0'), + 'checker.dark': color.hex('#808080') + }).sized({ + unit: 4, + gutter: 2, + pad: 4, + + row: 12, + bar: 12, + tab: 11, + tool: 15, + icon: 16, + swatch: 10, + check: 10, + scroll: 8, + checker: 16, + + // Aseprite rounds a corner by a single pixel where it rounds one at all. + radius: 2, + cut: 1, + + capTop: 2, + cap: 6, + baseline: 7, + descender: 9 + }) + + theme.native = 12 + + return theme +} diff --git a/chisel/themes/theme-named.gs b/chisel/themes/theme-named.gs index 22c8980..99dfe9d 100644 --- a/chisel/themes/theme-named.gs +++ b/chisel/themes/theme-named.gs @@ -3,11 +3,15 @@ import { ghostLight } from "chisel/themes/ghost-light" import { asepriteDark } from "chisel/themes/aseprite-dark" import { asepriteClassic } from "chisel/themes/aseprite-classic" import { picotron } from "chisel/themes/picotron" +import { asepriteMocha } from "chisel/themes/aseprite-mocha" +import { asepriteLatte } from "chisel/themes/aseprite-latte" // Every theme by name, so a preference can name one and a menu can list them. // -// Picotron is the default: it is the one the interface is measured against, -// and the only one whose colours all come from a fixed palette. +// Aseprite Mocha is the default: its layout and metrics are what the interface +// is measured against, and its colours come from a published palette rather +// than from anyone's judgement. Picotron is kept because its measurements are +// real and its flat-surface rules are what the painter is still built on. // // A function rather than a map because Ghost has no statics and a module-level // map would be built once and shared - and a Theme is mutable (it carries the @@ -15,8 +19,9 @@ import { picotron } from "chisel/themes/picotron" function themeNamed(name) { if (name == 'ghost.light') { return ghostLight() } if (name == 'picotron') { return picotron() } + if (name == 'aseprite.latte') { return asepriteLatte() } if (name == 'aseprite.dark') { return asepriteDark() } if (name == 'aseprite.classic') { return asepriteClassic() } - return picotron() + return asepriteMocha() } diff --git a/main.gs b/main.gs index 71fc220..a5f14d0 100644 --- a/main.gs +++ b/main.gs @@ -2,7 +2,7 @@ import "lumen:window" import "lumen:canvas" import "lumen:lumen" import { Studio } from "studio/studio" -import { picotron } from "chisel/themes/picotron" +import { asepriteMocha } from "chisel/themes/aseprite-mocha" import { logicalSize } from "chisel/support/logical-size" import { wantsScreenshot } from "chisel/support/wants-screenshot" import { requestedCommands } from "chisel/support/requested-commands" @@ -18,15 +18,15 @@ app = {} function load() { window.setTitle('Studio') - window.setMode(1440, 810) + window.setMode(1440, 900) window.setResizable(true) window.setVsync(true) - // Draw into a magnified low-resolution framebuffer, the way Picotron does. - // Its 12px rows and 7x7 icons only mean anything if one drawn pixel covers - // several screen pixels; the magnification is picked from the window so a - // larger monitor buys workspace rather than bigger chrome. - frame = logicalSize(1440, 810) + // Draw into a magnified framebuffer. Aseprite's 12px menu bar and 16px icons + // only mean anything if one drawn pixel covers several screen pixels; the + // magnification is picked from the window so a larger monitor buys workspace + // rather than bigger chrome. + frame = logicalSize(1440, 900) window.setLogicalSize(frame.w, frame.h) window.setPixelPerfect(true) @@ -34,7 +34,7 @@ function load() { // Scale 1: the framebuffer does the magnifying now, so every metric is used // at the size it was measured at. Ctrl+= changes the magnification instead, // which is the same control with an honest name. - theme = picotron() + theme = asepriteMocha() .useScale(1) .loadFonts(null) diff --git a/playground.gs b/playground.gs index 6952862..6fa2577 100644 --- a/playground.gs +++ b/playground.gs @@ -6,7 +6,7 @@ import { Rect } from "chisel/geometry/rect" import { Label } from "chisel/widgets/label" import { Icons } from "chisel/icons" import { Cursors } from "chisel/cursors" -import { picotron } from "chisel/themes/picotron" +import { asepriteMocha } from "chisel/themes/aseprite-mocha" import { logicalSize } from "chisel/support/logical-size" import { Gallery } from "playground/gallery" @@ -21,15 +21,15 @@ app = {} function load() { window.setTitle('Chisel - widget playground') - window.setMode(1440, 810) + window.setMode(1440, 900) window.setResizable(true) window.setVsync(true) // Draw into a small framebuffer and let the engine magnify the whole frame. - // Picotron's 12px rows and 7x7 icons only mean anything if a drawn pixel is + // Aseprite's 12px rows and 16px icons only mean anything if a drawn pixel is // several screen pixels wide; at the window's own resolution they would be // physically tiny rather than chunky. - frame = logicalSize(1440, 810) + frame = logicalSize(1440, 900) window.setLogicalSize(frame.w, frame.h) window.setPixelPerfect(true) @@ -38,12 +38,12 @@ function load() { // Scale 1: the framebuffer does the magnifying now, so the metrics are used // at the size they were measured. - theme = picotron().useScale(1).loadFonts(null) + theme = asepriteMocha().useScale(1).loadFonts(null) app.ui = new Ui(theme, new Painter(theme)) // 8x8 cells, 8 to a row, named in sheet order. Drawn by tools/make-icons.py. - app.ui.icons = new Icons('resources/icons.png', 8) + app.ui.icons = new Icons('resources/icons.png', 16) .define(['pencil', 'eraser', 'bucket', 'picker', 'select', 'move', 'line', 'rectangle']) .define(['ellipse', 'text', 'zoom', 'grid', 'layers', 'frame', 'play', 'stop']) .define(['undo', 'redo', 'save', 'open', 'plus', 'minus', 'check', 'close']) diff --git a/resources/icons.png b/resources/icons.png index 1f1b254de187660589c31af1a3e3949ddfd34599..7f3109f9ebd2f50e050f36990b0bbe802d1b00b4 100644 GIT binary patch literal 1281 zcmV+c1^)VpP)x2QcBYudG3v8<~(J{?HT|>etTYx?N|t%KanIr z)g3|W5AK)ttc{P%S=wTua)mMzT7Ogn5Xc9r+|f>Kk0!Ne1gm#4CBkO7XH2&A9F@C2 zYKzakfJ6i6kuMD^BWOrz;XXtlWf22Ro z`d#uPI2pj59+4#id*HMgp|fMoSAhxtW)j5CgPj>{@}swaGi}?a&MP%AzJq6&ekuh|iQnpz}U?84CGK7`L(=Li!`T9(w!&`vo++KBIL9paEU?UK!aPQT3ctRb9 zAm!@bdk<~?UR<3S9`x6EE$5_4di{?g)CAhYyoWfgB`Wjt+vSZhwzJInucex9y~~Zcpsm=|7ddW z-oyWA9R4=&3YXf+^Q}15tqfv4Jj)!FGXu15*1pnu+@FR`hnB+iRyEg8A$MPArHXe$ zIjd0TmYX~Jr(u^NiyYGTQy$zK+qW4ACLbTzk#ijFOQ*w_7ioegB>lc<nZ5mddc_V z@Igq=2bD2Rt_wG`@q00000NkvXXu0mjfcQJJm literal 490 zcmVr+NJNaZ!vN=bo@Qoaj1i4{ESt14Z6f=%-hl(?z&6i_vJEIFiJa8US2#Sm zEC5SL77Qz6*)FhnejF<;syrV@P3%!^KH3!8u??mI67)2zQosO(+EXHQzTZe*+W7*)TZV6Fu*KqNMu65J)k zk{*e!(tof7&&c^5nN^WhT32+d*CxZPP40m`nP&Ov>>hxch}#Ls?llr(w%PsO<)hdi z(X*pwe>kjGS0TYrHA=`qUH|F>cHZs?J_%|Mz~&W^j5u|8{5QH~8(o(SIR7pV3eV~a zy?h%~7GJ28-s^)BXBXJVFpYcqHpos2=akLtRz!Dg{LC6BoS%Nux1U*ehqgXQMlzbq zN)*MICCXP;)jO2E1v_2#IXl_z9jwN>sC+X%*H2>VyRQ=bG#DqQ3DX;~09cyYT<7yQ g!8G9)5Ay4H0MJO6_tWW4TmS$707*qoM6N<$f>ajfw*UYD diff --git a/shot.gs b/shot.gs index b217fe8..fc0a044 100644 --- a/shot.gs +++ b/shot.gs @@ -6,7 +6,7 @@ import { Painter } from "chisel/painter" import { Rect } from "chisel/geometry/rect" import { Icons } from "chisel/icons" import { Cursors } from "chisel/cursors" -import { picotron } from "chisel/themes/picotron" +import { asepriteMocha } from "chisel/themes/aseprite-mocha" import { logicalSize } from "chisel/support/logical-size" import { Gallery } from "playground/gallery" @@ -23,19 +23,19 @@ app = {} function load() { window.setTitle('chisel - gallery') - window.setMode(1440, 810) + window.setMode(1440, 900) - frame = logicalSize(1440, 810) + frame = logicalSize(1440, 900) window.setLogicalSize(frame.w, frame.h) window.setPixelPerfect(true) - theme = picotron().useScale(1).loadFonts(null) + theme = asepriteMocha().useScale(1).loadFonts(null) app.ui = new Ui(theme, new Painter(theme)) app.frames = 0 - app.ui.icons = new Icons('resources/icons.png', 8) + app.ui.icons = new Icons('resources/icons.png', 16) .define(['pencil', 'eraser', 'bucket', 'picker', 'select', 'move', 'line', 'rectangle']) .define(['ellipse', 'text', 'zoom', 'grid', 'layers', 'frame', 'play', 'stop']) .define(['undo', 'redo', 'save', 'open', 'plus', 'minus', 'check', 'close']) diff --git a/studio/sprite/editor.gs b/studio/sprite/editor.gs index f6e5376..dde5bc7 100644 --- a/studio/sprite/editor.gs +++ b/studio/sprite/editor.gs @@ -167,7 +167,7 @@ class SpriteEditor { dock.bottom(new Statusbar(studio).named('status'), theme.metric('row')) dock.left(colours, colours.widthFor(theme)) - dock.right(this.toolbar().named('tools'), theme.metric('tool') + 8) + dock.right(this.toolbar().named('tools'), theme.metric('icon') + 6) dock.bottom(new Timeline(document).named('timeline'), theme.metric('row') * 4) diff --git a/studio/studio.gs b/studio/studio.gs index 9f96f65..aca45d5 100644 --- a/studio/studio.gs +++ b/studio/studio.gs @@ -68,7 +68,7 @@ class Studio { // That mismatch is most of why the old sheet could not have looked right no // matter how well it was drawn. loadArt() { - this.ui.icons = new Icons('resources/icons.png', 8) + this.ui.icons = new Icons('resources/icons.png', 16) .define(['pencil', 'eraser', 'bucket', 'picker', 'select', 'move', 'line', 'rectangle']) .define(['ellipse', 'text', 'zoom', 'grid', 'layers', 'frame', 'play', 'stop']) .define(['undo', 'redo', 'save', 'open', 'plus', 'minus', 'check', 'close']) diff --git a/tests/core.gs b/tests/core.gs index fdc7772..c27b63d 100644 --- a/tests/core.gs +++ b/tests/core.gs @@ -475,28 +475,36 @@ check('a profile ends flush with the edge', cornerInsets(8).last(), 0) console.log('') console.log('Logical size') -// The magnification is chosen to put the logical height nearest Picotron's -// 270, and the window is then divided by it - so a bigger monitor buys -// workspace rather than margins. +// The magnification puts the logical height nearest the design's target, and +// the window is then divided by it - so a bigger monitor buys workspace rather +// than margins. 540 is Aseprite's, which is the default. full = logicalSize(1920, 1080) -check('1080p magnifies four times', full.scale, 4) -check('and gives exactly Picotron', `${full.w}x${full.h}`, '480x270') +check('1080p magnifies twice', full.scale, 2) +check('and gives exactly Aseprite', `${full.w}x${full.h}`, '960x540') laptop = logicalSize(1440, 900) -check('900 tall magnifies three times', laptop.scale, 3) -check('and gives more room than Picotron', `${laptop.w}x${laptop.h}`, '480x300') +check('900 tall still magnifies twice', laptop.scale, 2) +check('and gives less room than 1080p', `${laptop.w}x${laptop.h}`, '720x450') + +// The target is a parameter because two designs want different ones: Picotron +// was drawn for 270 and Aseprite for 540. Hard-coding either means the other +// renders at half or double the intended density. +picotron = logicalSize(1920, 1080, null, 270) + +check('the Picotron target magnifies four times', picotron.scale, 4) +check('and gives exactly Picotron', `${picotron.w}x${picotron.h}`, '480x270') // Whole numbers only. A fractional magnification resamples every drawn pixel // to a different width, which is the one thing that cannot be allowed. odd = logicalSize(1333, 777) -check('an awkward window still magnifies wholly', odd.scale, 3) -check('and divides down evenly', `${odd.w}x${odd.h}`, '444x259') +check('an awkward window still magnifies wholly', odd.scale, 1) +check('and divides down evenly', `${odd.w}x${odd.h}`, '1333x777') -check('a chosen magnification wins', logicalSize(1920, 1080, 2).scale, 2) -check('and is honoured', logicalSize(1920, 1080, 2).w, 960) +check('a chosen magnification wins', logicalSize(1920, 1080, 4).scale, 4) +check('and is honoured', logicalSize(1920, 1080, 4).w, 480) // A window smaller than one magnification would divide to nothing. check('magnification never falls below one', logicalSize(100, 100).scale, 1) diff --git a/tools/catppuccin.py b/tools/catppuccin.py new file mode 100644 index 0000000..24dff05 --- /dev/null +++ b/tools/catppuccin.py @@ -0,0 +1,92 @@ +"""Catppuccin Mocha and Latte, and a way to clean a lossy screenshot with them. + +The Aseprite references arrived as lossy WebP: 32,000 colours in a screenshot +of an interface that uses about thirty. Recovering exact values out of that is +impossible in the way the Picotron shift-map was possible, because the damage +is not a function - the same true colour comes back differently depending on +what surrounds it. + +It does not need to be recovered, only identified. The theme in those +screenshots is Catppuccin, whose values are published and exact, and five of +its colours survive the compression at distance nought (#1e1e2e, #eff1f5, +#dce0e8, #fe640b, #a6e3a1). Snapping to the published palette is therefore not +a guess dressed up as a measurement - it is reading a known answer off a +damaged copy. + +The one rule: snap only within a tolerance. A pixel far from every entry is an +antialiased edge or artwork, and pretending otherwise is how the Picotron +nearest-entry bug would have crept back in. +""" + +import math + +MOCHA = { + "rosewater": "#f5e0dc", "flamingo": "#f2cdcd", "pink": "#f5c2e7", + "mauve": "#cba6f7", "red": "#f38ba8", "maroon": "#eba0ac", + "peach": "#fab387", "yellow": "#f9e2af", "green": "#a6e3a1", + "teal": "#94e2d5", "sky": "#89dceb", "sapphire": "#74c7ec", + "blue": "#89b4fa", "lavender": "#b4befe", "text": "#cdd6f4", + "subtext1": "#bac2de", "subtext0": "#a6adc8", "overlay2": "#9399b2", + "overlay1": "#7f849c", "overlay0": "#6c7086", "surface2": "#585b70", + "surface1": "#45475a", "surface0": "#313244", "base": "#1e1e2e", + "mantle": "#181825", "crust": "#11111b", +} + +LATTE = { + "rosewater": "#dc8a78", "flamingo": "#dd7878", "pink": "#ea76cb", + "mauve": "#8839ef", "red": "#d20f39", "maroon": "#e64553", + "peach": "#fe640b", "yellow": "#df8e1d", "green": "#40a02b", + "teal": "#179299", "sky": "#04a5e5", "sapphire": "#209fb5", + "blue": "#1e66f5", "lavender": "#7287fd", "text": "#4c4f69", + "subtext1": "#5c5f77", "subtext0": "#6c6f85", "overlay2": "#7c7f93", + "overlay1": "#8c8fa1", "overlay0": "#9ca0b0", "surface2": "#acb0be", + "surface1": "#bcc0cc", "surface0": "#ccd0da", "base": "#eff1f5", + "mantle": "#e6e9ef", "crust": "#dce0e8", +} + +# Aseprite paints the transparency checkerboard in these regardless of theme. +CHECKER = {"checker.light": "#c0c0c0", "checker.dark": "#808080"} + + +def rgb(value): + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + + +def nearest(colour, palette): + """The closest entry and its distance. Distance is the caller's business.""" + best, best_distance = None, float("inf") + + for name, value in palette.items(): + distance = math.dist(colour, rgb(value)) + + if distance < best_distance: + best, best_distance = name, distance + + return best, best_distance + + +def clean(image, palette, tolerance=6.0): + """Snap every pixel within `tolerance` of a palette entry onto it. + + Anything further away is left alone: it is an antialiased edge, artwork, or + a colour the palette does not contain, and snapping it would invent a + measurement rather than recover one. + """ + full = dict(palette) + full.update(CHECKER) + + out = image.copy() + pixels = out.load() + cache = {} + + for y in range(out.height): + for x in range(out.width): + colour = pixels[x, y][:3] + + if colour not in cache: + name, distance = nearest(colour, full) + cache[colour] = rgb(full[name]) if distance <= tolerance else colour + + pixels[x, y] = cache[colour] + + return out diff --git a/tools/lint.py b/tools/lint.py index 5a5c4aa..a4b06c7 100755 --- a/tools/lint.py +++ b/tools/lint.py @@ -22,6 +22,7 @@ Run it with `python3 tools/lint.py`; it exits non-zero if anything is found. """ +import os import re, glob, collections, sys def strip_strings(line): @@ -176,6 +177,37 @@ def match_call_args(text, start): print(f"locals {path}:{line} local `{name}` shadows `this.{name}()`, called in the same method") problems += 1 +# --- palette: a theme may not invent a colour ------------------------------- +# +# The original complaint about this interface was that it read as messy and +# inconsistent, and a large part of that was forty hand-picked hex values that +# only nearly agreed with each other. A colour two off a real palette entry +# looks fine on its own and wrong beside everything else. +# +# So the themes that claim a palette must actually use it: every colour comes +# from a named lookup, and a raw hex literal has to be listed here with a +# reason. Both current exceptions are measured facts about Aseprite rather than +# choices - it paints the transparency checker itself, in the same two greys, +# under both the light and the dark reference. +PALETTE_THEMES = { + "chisel/themes/aseprite-mocha.gs": {"#c0c0c0", "#808080"}, + "chisel/themes/aseprite-latte.gs": {"#c0c0c0", "#808080"}, +} + +for path, allowed in PALETTE_THEMES.items(): + if not os.path.exists(path): + continue + + src = open(path).read() + + for m in re.finditer(r"'(#[0-9a-fA-F]{6})'", src): + if m.group(1) in allowed: + continue + + line = src[:m.start()].count("\n") + 1 + print(f"palette {path}:{line} raw hex {m.group(1)} - use a palette name, or list it as an exception") + problems += 1 + print() print(f"{problems} problem(s); {len(unique)} uniquely-named callables checked") diff --git a/tools/make-icons.py b/tools/make-icons.py index b9d0612..a802071 100755 --- a/tools/make-icons.py +++ b/tools/make-icons.py @@ -7,23 +7,28 @@ is a readable diff instead of a binary blob nobody can review. The PNGs are build output; this file is the source. -Picotron uses two different icon languages, and the difference is not -decorative: - - Control icons - toolbar buttons, tools, arrows - are monochrome 7x7 - silhouettes in one colour, drawn on the toolbar's own ground with no outline. - Because they are one colour they can be tinted at draw time, so a single - sheet serves normal, dimmed, hovered and selected states and a theme swap - recolours all of them at once. White on transparent, tinted on use. - - File icons - folder, document, cartridge - are full-colour 15x16 art with a - 1px #1d2b53 outline and two fill tones. These are pictures, not symbols; - tinting one would destroy it. They ship at their real colours. - -The size matters as much as the style. Picotron's control icons are 7x7 in an -8x8 cell, not 16x16. On a 480x270 framebuffer a 16x16 tool button is more than -twice the height of the row it sits in, which is most of why the old sheet -could never have looked right no matter how it was drawn. +Two different icon languages, and the difference is not decorative: + + Control icons - toolbar buttons, tools, arrows - are 16x16 and monochrome, + drawn white on transparent and tinted at draw time, so one sheet serves + normal, dimmed, hovered and selected states and a theme swap recolours all of + them at once. + + They carry two tones rather than one. Aseprite's tool icons are not flat + silhouettes - the pencil has a lit body and a dark tip, the bucket a lit face + and a shaded side - and a silhouette throws that away. The second tone is + encoded as half alpha in the same sheet: tinting multiplies, so `+` comes out + as the tint at 50% over whatever is behind it, which is exactly a shade. One + sheet, one draw call, no second colour to thread through the theme. + + File icons - folder, document, cartridge - are full-colour 16x16 art with a + 1px outline and two fill tones, lifted pixel for pixel from Picotron's own + icon browser. These are pictures, not symbols; tinting one would destroy it. + +The size matters as much as the style. Aseprite's control icons are 16x16 on a +960x540 framebuffer - a quarter of the density Picotron's 7x7 icons had on +480x270 - so this sheet is not the Picotron sheet enlarged, it is drawn for a +different grid. """ import os import sys @@ -32,41 +37,210 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -# --- control icons: 7x7 monochrome, tinted at draw time ---------------------- +# --- control icons: 16x16, two tones, tinted at draw time -------------------- # -# `#` is ink, `.` is transparent. Eight per row, in the order Studio binds them. -ICON_CELL = 8 +# `#` is full ink, `+` is the same tint at half alpha (a shade), `.` is clear. +# Eight per row, in the order Studio binds them. +ICON_CELL = 16 ICONS = { - # Drawing tools. - "pencil": ["....##.", "...###.", "..###..", ".###...", "###....", "##.....", "#......"], - "eraser": ["..#####", ".##...#", "##...##", "#...##.", "#..##..", "####...", "......."], - "bucket": [".##....", "####...", "#####..", ".####.#", "..##..#", "......#", ".....##"], - "picker": ["...####", "...#..#", "..####.", ".##....", "##.....", "#......", "......."], - "select": ["##.##.#", "#.....#", "#.....#", ".......", "#.....#", "#.....#", "#.##.##"], - "move": ["...#...", "..###..", "...#...", "#.###.#", "...#...", "..###..", "...#..."], - "line": ["......#", ".....#.", "....#..", "...#...", "..#....", ".#.....", "#......"], - "rectangle": ["#######", "#.....#", "#.....#", "#.....#", "#.....#", "#.....#", "#######"], + # Drawing tools, drawn in Aseprite's idiom: a lit body, a shaded edge, and + # enough internal space that the shape still reads at 16px. + "pencil": [ + "..........####..", ".........##++##.", "........##++++#.", + ".......##++++##.", "......##++++##..", ".....##++++##...", + "....##++++##....", "...##++++##.....", "..##++++##......", + ".##++++##.......", "##++++##........", "#++++##.........", + "#+++##..........", "#++##...........", "####............", + "##..............", + ], + "eraser": [ + "................", "................", "......########..", + ".....##++++++#..", "....##++++++##..", "...##++++++##...", + "..##++++++##....", ".##++++++##.....", "##++++++##......", + "#######+##......", "#+++++##........", "#+++++#.........", + "#++++##.........", "#++++#..........", "#######.........", + "................", + ], + "bucket": [ + "................", "..############..", "..#++++++++++#..", + "...#++++++++#...", "...#++++++++#...", "...#++++++++#.#.", + "....#++++++#.#+#", "....#++++++#.#+#", "....#++++++#..#.", + ".....#++++#.....", ".....#++++#.....", "......####......", + "................", "................", "................", + "................", + ], + "picker": [ + "..........#####.", ".........#+++++#", ".........#+++++#", + "..........#####.", ".........###....", "........###.....", + ".......###......", "......###.......", ".....###........", + "....###.........", "...###..........", "..###...........", + ".###............", "###.............", "##..............", + "#...............", + ], + "select": [ + "................", ".##.##.##.##.##.", ".#............#.", + ".#............#.", "................", ".#............#.", + ".#............#.", "................", ".#............#.", + ".#............#.", "................", ".#............#.", + ".#............#.", ".##.##.##.##.##.", "................", + "................", + ], + "move": [ + ".......#........", "......###.......", ".....##+##......", + "....#..#..#.....", ".......#........", "..#....#....#...", + ".##....#....##..", "###############.", ".##....#....##..", + "..#....#....#...", ".......#........", "....#..#..#.....", + ".....##+##......", "......###.......", ".......#........", + "................", + ], + "line": [ + "................", "............###.", "...........##+#.", + "..........##+##.", ".........##+##..", "........##+##...", + ".......##+##....", "......##+##.....", ".....##+##......", + "....##+##.......", "...##+##........", "..##+##.........", + ".##+##..........", ".#+##...........", ".###............", + "................", + ], + "rectangle": [ + "................", "................", "..############..", + "..#++++++++++#..", "..#+........+#..", "..#+........+#..", + "..#+........+#..", "..#+........+#..", "..#+........+#..", + "..#+........+#..", "..#+........+#..", "..#++++++++++#..", + "..############..", "................", "................", + "................", + ], # Shapes and view. - "ellipse": ["..###..", ".#...#.", "#.....#", "#.....#", "#.....#", ".#...#.", "..###.."], - "text": ["#######", "...#...", "...#...", "...#...", "...#...", "...#...", "...#..."], - "zoom": [".###...", "#...#..", "#...#..", "#...#..", ".###...", "....##.", ".....##"], - # Measured off Picotron's own view toggle, pixel for pixel. - "grid": ["###.###", "###.###", "###.###", ".......", "###.###", "###.###", "###.###"], - "layers": [".#####.", ".#...#.", ".#####.", "#####.#", "#...#..", "#...#..", "#####.."], - "frame": ["##...##", "#.....#", ".......", ".......", ".......", "#.....#", "##...##"], - "play": ["#......", "##.....", "####...", "######.", "####...", "##.....", "#......"], - "stop": [".......", ".#####.", ".#####.", ".#####.", ".#####.", ".#####.", "......."], - - # History and files. - "undo": ["..#....", ".##....", "#####..", ".##..#.", "..#..#.", ".....#.", "..####."], - "redo": ["....#..", "....##.", "..#####", ".#..##.", ".#...#.", ".#.....", ".####.."], - "save": ["#######", "#.###.#", "#.###.#", "#.....#", "#.###.#", "#.###.#", "#######"], - "open": ["###....", "#..#...", "#######", "#.....#", "#.....#", "#.....#", "#######"], - "plus": ["...#...", "...#...", "...#...", "#######", "...#...", "...#...", "...#..."], - "minus": [".......", ".......", ".......", "#######", ".......", ".......", "......."], - "check": ["......#", ".....#.", "#...#..", ".#.#...", "..#....", ".......", "......."], - "close": ["#.....#", ".#...#.", "..#.#..", "...#...", "..#.#..", ".#...#.", "#.....#"], + "ellipse": [ + "................", ".....######.....", "...##++++++##...", + "..#++......++#..", ".##..........##.", ".#+..........+#.", + "#+............+#", "#+............+#", "#+............+#", + ".#+..........+#.", ".##..........##.", "..#++......++#..", + "...##++++++##...", ".....######.....", "................", + "................", + ], + "text": [ + "................", ".##############.", ".##++++##++++##.", + ".#+.....##.....#", "........##......", "........##......", + "........##......", "........##......", "........##......", + "........##......", "........##......", "........##......", + "......######....", "......######....", "................", + "................", + ], + "zoom": [ + "................", "....######......", "...##++++##.....", + "..##+....+##....", ".##+......+##...", ".#+........+#...", + ".#+........+#...", ".##+......+##...", "..##+....+##....", + "...##++++###....", "....######+##...", "..........+###..", + "...........+###.", "............+###", ".............+##", + "..............##", + ], + "grid": [ + "................", ".##############.", ".#++++#++++#+++#", + ".#++++#++++#+++#", ".#++++#++++#+++#", ".##############.", + ".#++++#++++#+++#", ".#++++#++++#+++#", ".#++++#++++#+++#", + ".##############.", ".#++++#++++#+++#", ".#++++#++++#+++#", + ".#++++#++++#+++#", ".##############.", "................", + "................", + ], + "layers": [ + "................", "......####......", "....##++++##....", + "..##++++++++##..", "##++++++++++++##", "..##++++++++##..", + "....##++++##....", "......####......", "..##++++++++##..", + "##++++++++++++##", "..##++++++++##..", "....##++++##....", + "......####......", "................", "................", + "................", + ], + "frame": [ + "................", ".##############.", ".#+#..#..#..#+#.", + ".#+#..#..#..#+#.", ".##############.", ".#++++++++++++#.", + ".#++++++++++++#.", ".#++++++++++++#.", ".#++++++++++++#.", + ".#++++++++++++#.", ".##############.", ".#+#..#..#..#+#.", + ".#+#..#..#..#+#.", ".##############.", "................", + "................", + ], + "play": [ + "................", "...##...........", "...####.........", + "...######.......", "...########.....", "...##++#####....", + "...##++++#####..", "...##++++++####.", "...##++++++####.", + "...##++++#####..", "...##++#####....", "...########.....", + "...######.......", "...####.........", "...##...........", + "................", + ], + "stop": [ + "................", "................", "..############..", + "..#++++++++++#..", "..#++++++++++#..", "..#++++++++++#..", + "..#++++++++++#..", "..#++++++++++#..", "..#++++++++++#..", + "..#++++++++++#..", "..#++++++++++#..", "..#++++++++++#..", + "..############..", "................", "................", + "................", + ], + + # Editing and files. + "undo": [ + "................", "....##..........", "...###..........", + "..####..........", ".#####..........", "##########......", + ".#####++++###...", "..####.....##...", "...###.....+##..", + "....##......+#..", "............+#..", "............##..", + "...........##...", "................", "................", + "................", + ], + "redo": [ + "................", "..........##....", "..........###...", + "..........####..", "..........#####.", "......##########", + "...###++++#####.", "...##.....####..", "..##+.....###...", + "..#+......##....", "..#+............", "..##............", + "...##...........", "................", "................", + "................", + ], + "save": [ + "................", ".##############.", ".#+##########+#.", + ".#+#........#+#.", ".#+#........#+#.", ".#+##########+#.", + ".#++++++++++++#.", ".#++++++++++++#.", ".#+##########+#.", + ".#+#++++++++#+#.", ".#+#++++++++#+#.", ".#+#++++++++#+#.", + ".#+#++++++++#+#.", ".##############.", "................", + "................", + ], + "open": [ + "................", "................", "..#####.........", + ".##+++##........", "################", "#++++++++++++++#", + "#++++++++++++++#", "#++++++++++++++#", "#++++++++++++++#", + "#++++++++++++++#", "#++++++++++++++#", "#++++++++++++++#", + "#++++++++++++++#", "################", "................", + "................", + ], + "plus": [ + "................", "................", ".......##.......", + ".......##.......", ".......##.......", ".......##.......", + ".......##.......", "..############..", "..############..", + ".......##.......", ".......##.......", ".......##.......", + ".......##.......", ".......##.......", "................", + "................", + ], + "minus": [ + "................", "................", "................", + "................", "................", "................", + "................", "..############..", "..############..", + "................", "................", "................", + "................", "................", "................", + "................", + ], + "check": [ + "................", "................", "..............##", + ".............##+", "............##+.", "...........##+..", + "..#.......##+...", "..##.....##+....", "..###...##+.....", + "...###.##+......", "....#####.......", ".....###........", + "......#.........", "................", "................", + "................", + ], + "close": [ + "................", "................", "..##........##..", + "..###......###..", "...###....###...", "....###..###....", + ".....######.....", "......####......", "......####......", + ".....######.....", "....###..###....", "...###....###...", + "..###......###..", "..##........##..", "................", + "................", + ], } # --- file icons: full colour, 15x16, as measured off Picotron ----------------- @@ -195,7 +369,9 @@ def main(): resources = os.path.join(ROOT, "resources") # White, so that a tint at draw time is a straight multiply. - icons = sheet(ICONS, ICON_CELL, 8, lambda mark: (255, 255, 255, 255)) + # `+` is the same white at half alpha. Tinting multiplies, so it lands as + # the tint at 50% over the ground - a shade, from one sheet. + icons = sheet(ICONS, ICON_CELL, 8, lambda mark: (255, 255, 255, 128 if mark == "+" else 255)) icons.save(os.path.join(resources, "icons.png")) glyphs = sheet(GLYPHS, GLYPH_CELL, 8, lambda mark: PAINTS[mark] + (255,)) From a9c6169070acbf8ff49b8b806c797141b74ac82e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:41:27 +0000 Subject: [PATCH 2/4] Add Aseprite's colour picker, and correct a Ghost papercut I had understated The left panel now runs palette, saturation-value field, hue strip, foreground and background chips - Aseprite's order, and the right way round: the palette is what you use constantly and belongs where the eye lands, the picker is for when the palette has not got the colour, the chips record the last two decisions. This is the first thing in Studio that constructs a colour rather than choosing one from a fixed set, so it is the first that needs HSV. The conversion is a pure function with tests on all six sector boundaries and on the clamping, which matters because a drag that runs off the field hands back a component slightly out of range and unclamped that wraps 256 to black - the picker would appear to break exactly when the pointer left it. The field is cached in an off-screen Target. Drawn per pixel per frame it is six thousand rectangles sixty times a second to produce an image that only changes when the hue does. Two rendering faults, neither visible in the code. The Target blit came out a near-black smear because a blit is multiplied by the current draw colour and the last thing set was the well's own background. And the marker ring hung two pixels above the gradient, because full saturation and value puts its centre on the top-right pixel - which is the default, so it was wrong on first sight. The important part is the papercut. I described it in an earlier commit as "a local whose name matches a method of the same class shadows that method for the whole call". That is wrong twice over, and the narrow rule let a second instance straight through. Reduced: class Probe { gap() { return 7 } first() { gap = 99; return gap } second() { return this.gap() } } p = new Probe(); p.second(); p.first(); p.second() prints 7, then raises. A local does not shadow a method for a call - it destroys that method on the object permanently, from any other method, and the same call works before the poisoning line runs and fails after. It is time-dependent, so testing cannot be relied on to surface it. The linter now flags any local sharing a name with any method of its class, which found the one this rule was rewritten for and one more: fillRounded reassigned its `radius` parameter, which would have destroyed painter.radius() - called by Window and Colorbar for their corners - for the rest of the session. It is latent rather than live only because the Picotron rebuild left fillRounded with no callers. 132 assertions pass, the linter is clean, Picotron's eight tiles still match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM --- chisel/painter.gs | 19 ++- chisel/support/hsv.gs | 54 +++++++++ studio/sprite/colorbar.gs | 63 +++++++++- studio/sprite/colorpicker.gs | 224 +++++++++++++++++++++++++++++++++++ tests/core.gs | 39 ++++++ tools/lint.py | 50 +++++--- 6 files changed, 419 insertions(+), 30 deletions(-) create mode 100644 chisel/support/hsv.gs create mode 100644 studio/sprite/colorpicker.gs diff --git a/chisel/painter.gs b/chisel/painter.gs index ef570f2..b8ad3b6 100644 --- a/chisel/painter.gs +++ b/chisel/painter.gs @@ -201,9 +201,12 @@ class Painter { } fillRounded(rect, paint, radius, corners = null) { - radius = this.fittedRadius(rect, radius) + // `fitted`, not `radius`. Reassigning the parameter would permanently + // destroy this.radius() on the painter - Ghost does not scope a method's + // locals to that method - and two widgets call it for their corner. + fitted = this.fittedRadius(rect, radius) - if (radius < 1) { + if (fitted < 1) { return this.fill(rect, paint) } @@ -211,12 +214,16 @@ class Painter { corners = this.allCorners() } - insets = cornerInsets(radius) + insets = cornerInsets(fitted) - // The straight middle in one draw, then one thin row per corner step. - this.fill(new Rect(rect.x, rect.y + radius, rect.w, rect.h - radius * 2), paint) + // The straight middle in one draw, then one thin row per corner step. Every + // use below is `fitted` rather than `radius`: a corner larger than half the + // rectangle would otherwise draw rows that overlap in the middle and leave + // the shape with a bite out of it, which is the whole reason fittedRadius + // exists. + this.fill(new Rect(rect.x, rect.y + fitted, rect.w, rect.h - fitted * 2), paint) - for (step = 0; step < radius; step++) { + for (step = 0; step < fitted; step++) { cut = insets[step] topLeft = 0 diff --git a/chisel/support/hsv.gs b/chisel/support/hsv.gs new file mode 100644 index 0000000..22ee880 --- /dev/null +++ b/chisel/support/hsv.gs @@ -0,0 +1,54 @@ +import "ghost:math" + +// Hue, saturation and value to red, green and blue. +// +// A colour picker needs this and a palette does not, which is why it arrives +// only now: everything before this point chose from a fixed set of colours, +// and picking from a continuous field is the first thing that has to construct +// one. +// +// `hue` is degrees in [0, 360), `saturation` and `value` are [0, 1]. Returns +// three integers in [0, 255], because that is what a colour constructor wants +// and rounding once here beats rounding at every call site. +function hsvToRgb(hue, saturation, value) { + if (saturation <= 0) { + level = channel(value) + + return { r: level, g: level, b: level } + } + + // Six sectors of sixty degrees. `slice` is how far through its own sector + // the hue has travelled, which is what makes the ramp continuous across a + // boundary rather than stepping at it. + sector = math.floor(hue / 60.0) % 6 + slice = (hue / 60.0) - math.floor(hue / 60.0) + + dark = value * (1 - saturation) + falling = value * (1 - (slice * saturation)) + rising = value * (1 - ((1 - slice) * saturation)) + + if (sector == 0) { return { r: channel(value), g: channel(rising), b: channel(dark) } } + if (sector == 1) { return { r: channel(falling), g: channel(value), b: channel(dark) } } + if (sector == 2) { return { r: channel(dark), g: channel(value), b: channel(rising) } } + if (sector == 3) { return { r: channel(dark), g: channel(falling), b: channel(value) } } + if (sector == 4) { return { r: channel(rising), g: channel(dark), b: channel(value) } } + + return { r: channel(value), g: channel(dark), b: channel(falling) } +} + +// One [0, 1] component as a byte, clamped rather than trusted: a saturation +// slightly over one from a drag that ran off the edge of the field would +// otherwise produce 256 and wrap to black. +function channel(level) { + scaled = math.floor((level * 255) + 0.5) + + if (scaled < 0) { + return 0 + } + + if (scaled > 255) { + return 255 + } + + return scaled +} diff --git a/studio/sprite/colorbar.gs b/studio/sprite/colorbar.gs index 7da6766..9ca7963 100644 --- a/studio/sprite/colorbar.gs +++ b/studio/sprite/colorbar.gs @@ -1,18 +1,34 @@ import "ghost:math" import { Widget } from "chisel/widget" import { Rect } from "chisel/geometry/rect" +import { ColorPicker } from "studio/sprite/colorpicker" -// Aseprite's colour bar: the palette grid, and under it the foreground and -// background colours as two overlapping chips. +// The handler is built outside the class because a closure made inside one +// cannot capture `this` in Ghost. +function makePickHandler(bar) { + return function (chosen) { + bar.document.palette[bar.document.foreground] = chosen + } +} + +// Aseprite's colour bar, top to bottom: the palette grid, the saturation-value +// picker with its hue strip, then the foreground and background chips. // -// The chips are the part people actually use - left-click paints foreground, -// right-click background - so they get real estate rather than a legend. +// That order is Aseprite's and it is the right way round. The palette is what +// you use constantly and belongs where the eye lands first; the picker is what +// you use when the palette does not already have the colour, so it takes the +// space left over; the chips show what the last two decisions were. class Colorbar extends Widget { constructor(document) { super.constructor('colorbar') this.document = document this.columns = 4 + + this.picker = new ColorPicker(document) + this.picker.on('change', makePickHandler(this)) + + this.add(this.picker) } across(count) { @@ -55,9 +71,8 @@ class Colorbar extends Widget { chipsRect(ui) { size = this.swatchSize(ui.theme) * 2 + 4 inner = this.bounds.inset(ui.theme.metric('gutter')) - grid = this.gridRect(ui) - return new Rect(inner.x, grid.bottom() + ui.theme.metric('pad'), inner.w, size) + return new Rect(inner.x, inner.bottom() - size, inner.w, size) } indexAt(ui, x, y) { @@ -80,7 +95,38 @@ class Colorbar extends Widget { return found } + // The picker is placed here rather than in arrange(), which has no theme to + // measure with. Placing is cheap - it sets a rectangle - and the picker's own + // cache is keyed on size rather than on being placed, so this costs nothing + // per frame. + placePicker(ui) { + grid = this.gridRect(ui) + chips = this.chipsRect(ui) + inner = this.bounds.inset(ui.theme.metric('gutter')) + + // `spacing`, not `gap`: a local named `gap` would destroy this.gap(), which + // gridRect() above calls to size the palette. + spacing = ui.theme.metric('pad') + + top = grid.bottom() + spacing + + // Whatever room is left, but no taller than the picker asks for. Filling a + // full-height dock made the saturation-value field a tall ribbon, where + // Aseprite's is roughly square and reads as a field of colour rather than + // as a gradient strip. + available = chips.y - spacing - top + wanted = this.picker.heightFor(ui.theme) + + height = math.max(ui.theme.metric('row'), math.min(available, wanted)) + + this.picker.place(new Rect(inner.x, top, inner.w, height)) + + return this + } + paint(ui) { + this.placePicker(ui) + ui.painter.panel(this.bounds, null) grid = this.gridRect(ui) @@ -122,6 +168,11 @@ class Colorbar extends Widget { ) this.paintChips(ui) + + // Children last, so the picker sits over the panel rather than under it. + // This bar painted itself and stopped for as long as it had no children; + // adding one made the omission a bug rather than a redundancy. + super.paint(ui) } paintChips(ui) { diff --git a/studio/sprite/colorpicker.gs b/studio/sprite/colorpicker.gs new file mode 100644 index 0000000..32d497b --- /dev/null +++ b/studio/sprite/colorpicker.gs @@ -0,0 +1,224 @@ +import "ghost:math" +import "lumen:canvas" +import "lumen:color" +import { Target } from "lumen:canvas" +import { Rect } from "chisel/geometry/rect" +import { Widget } from "chisel/widget" +import { hsvToRgb } from "chisel/support/hsv" + +// Aseprite's colour picker: a saturation-value field with a hue strip under it. +// +// This is the first thing in Studio that constructs a colour rather than +// choosing one from a fixed set, which is why it needs HSV at all. The palette +// beside it still picks from sixteen; this picks from the whole space and +// writes the result into the foreground slot. +// +// The field is drawn into an off-screen Target and blitted, not drawn per +// pixel per frame. At the sizes involved that is some six thousand rectangles +// a frame, sixty times a second, to produce an image that only changes when +// the hue does - which is once per drag on the strip and never otherwise. +class ColorPicker extends Widget { + constructor(document) { + super.constructor('colorpicker') + + this.document = document + this.hue = 0 + this.saturation = 1 + this.value = 1 + + this.field = null + this.stale = true + this.dragging = 'none' + this.focusable = true + } + + // Tall enough for a square-ish field plus the strip. The panel gives it what + // width it has, so height is the only thing this gets to ask for. + heightFor(theme) { + return theme.metric('row') * 6 + theme.metric('gutter') + } + + stripHeight(theme) { + return theme.metric('row') + } + + fieldRect(ui) { + inner = this.bounds.inset(1) + strip = this.stripHeight(ui.theme) + ui.theme.metric('gutter') + + return new Rect(inner.x, inner.y, inner.w, math.max(1, inner.h - strip)) + } + + hueRect(ui) { + inner = this.bounds.inset(1) + strip = this.stripHeight(ui.theme) + + return new Rect(inner.x, inner.bottom() - strip, inner.w, strip) + } + + // ---- the cached field ------------------------------------------------------ + + // Rebuilt only when the hue moves or the widget is resized. One column per + // pixel of width, one row per pixel of height, which is exactly the + // resolution the blit will show. + refresh(rect) { + width = math.max(1, math.floor(rect.w)) + height = math.max(1, math.floor(rect.h)) + + resized = this.field == null or this.width != width or this.height != height + + // Size is checked here rather than invalidated in arrange(), because the + // owning panel places this widget every frame - so an arrange() that set + // the flag would rebuild six thousand pixels sixty times a second to + // produce the identical image. + if (!this.stale and !resized) { + return false + } + + if (resized) { + this.field = new Target(width, height) + this.width = width + this.height = height + } + + canvas.setTarget(this.field) + + for (y = 0; y < height; y++) { + for (x = 0; x < width; x++) { + tone = hsvToRgb(this.hue, x / (width * 1.0), 1 - (y / (height * 1.0))) + + canvas.setColor(color.rgb(tone.r, tone.g, tone.b)) + canvas.filledRectangle(x, y, 1, 1) + } + } + + canvas.setTarget() + + this.stale = false + + return true + } + + // ---- painting -------------------------------------------------------------- + + paint(ui) { + field = this.fieldRect(ui) + strip = this.hueRect(ui) + + this.refresh(field) + + ui.painter.fill(this.bounds, ui.theme.of('panel.well')) + + // White first. A Target blit is multiplied by the current draw colour, and + // the last thing set was the well's own near-black - which multiplied the + // whole gradient down to a barely-visible smear. Nothing about the picker + // looked wrong in the code; it just came out dark. + canvas.setColor(color.rgb(255, 255, 255)) + + this.field.draw(field.x, field.y, 0, 1, 1) + + // The hue strip is drawn straight, not cached: it is one row of columns + // and never changes, so a Target would cost more than it saves. + for (x = 0; x < strip.w; x++) { + tone = hsvToRgb((x / (strip.w * 1.0)) * 360, 1, 1) + + ui.painter.fill( + new Rect(strip.x + x, strip.y, 1, strip.h), + color.rgb(tone.r, tone.g, tone.b) + ) + } + + this.paintMarkers(ui, field, strip) + } + + // A ring on the field and a bar on the strip, both drawn in two colours so + // they stay visible over any part of the gradient underneath - a white ring + // vanishes on white, and a black one vanishes in the corner below it. + paintMarkers(ui, field, strip) { + // Clamped inside the field. At full saturation and value the marker's + // centre is the top-right pixel, so an unclamped ring hangs two pixels + // outside the gradient and reads as floating above it - which is exactly + // where it sat on the first render, since full-and-full is the default. + size = 5 + left = field.x + math.floor(this.saturation * (field.w - 1)) - 2 + top = field.y + math.floor((1 - this.value) * (field.h - 1)) - 2 + + at = new Rect( + math.clamp(left, field.x, field.right() - size), + math.clamp(top, field.y, field.bottom() - size), + size, + size + ) + + ui.painter.outline(at.inset(-1)) + ui.painter.outline(at) + + mark = strip.x + math.floor((this.hue / 360.0) * (strip.w - 1)) + + ui.painter.fill(new Rect(mark - 1, strip.y, 3, strip.h), ui.theme.of('outline')) + ui.painter.fill(new Rect(mark, strip.y, 1, strip.h), ui.theme.of('text.normal')) + } + + // ---- picking --------------------------------------------------------------- + + commit() { + tone = hsvToRgb(this.hue, this.saturation, this.value) + + this.fire('change', color.rgb(tone.r, tone.g, tone.b)) + + return this + } + + takeField(ui, x, y) { + field = this.fieldRect(ui) + + this.saturation = math.clamp((x - field.x) / math.max(1, field.w - 1), 0, 1) + this.value = 1 - math.clamp((y - field.y) / math.max(1, field.h - 1), 0, 1) + + return this.commit() + } + + takeHue(ui, x) { + strip = this.hueRect(ui) + + this.hue = math.clamp((x - strip.x) / math.max(1, strip.w - 1), 0, 1) * 360 + this.stale = true + + return this.commit() + } + + pressed(ui) { + if (ui.pointer.button != 'left') { + return false + } + + ui.capture(this) + + if (this.hueRect(ui).contains(ui.pointer.x, ui.pointer.y)) { + this.dragging = 'hue' + + return this.takeHue(ui, ui.pointer.x) != null + } + + this.dragging = 'field' + + return this.takeField(ui, ui.pointer.x, ui.pointer.y) != null + } + + // The drag continues in whichever control it started in, however far the + // pointer wanders: sliding off the field onto the strip must not start + // changing the hue halfway through choosing a shade. + dragged(ui) { + if (this.dragging == 'hue') { + return this.takeHue(ui, ui.pointer.x) != null + } + + return this.takeField(ui, ui.pointer.x, ui.pointer.y) != null + } + + released(ui) { + this.dragging = 'none' + + return true + } +} diff --git a/tests/core.gs b/tests/core.gs index c27b63d..0863651 100644 --- a/tests/core.gs +++ b/tests/core.gs @@ -21,6 +21,7 @@ import { normalizeChord } from "chisel/support/normalize-chord" import { chamfer } from "chisel/support/chamfer" import { logicalSize } from "chisel/support/logical-size" import { fitZoom } from "chisel/support/fit-zoom" +import { hsvToRgb } from "chisel/support/hsv" import { paletteRamps } from "chisel/support/palette-ramps" import { paletteExtras } from "chisel/support/palette-extras" import { rampStep } from "chisel/support/ramp-step" @@ -533,6 +534,44 @@ check('an oversized document stays at 1:1', fitZoom(512, 512, 100, 100), 1) check('a region of nothing still gives a zoom', fitZoom(32, 32, 0, 0), 1) check('a document of nothing does not divide by it', fitZoom(0, 0, 100, 100), 1) +// --- hue, saturation, value ----------------------------------------------------------- + +console.log('') +console.log('HSV') + +// The six primaries sit exactly on sector boundaries, which is where an +// off-by-one in the sector arithmetic shows up first. +red = hsvToRgb(0, 1, 1) +check('hue 0 is red', `${red.r},${red.g},${red.b}`, '255,0,0') +green = hsvToRgb(120, 1, 1) +check('hue 120 is green', `${green.r},${green.g},${green.b}`, '0,255,0') +blue = hsvToRgb(240, 1, 1) +check('hue 240 is blue', `${blue.r},${blue.g},${blue.b}`, '0,0,255') + +yellow = hsvToRgb(60, 1, 1) +check('hue 60 is yellow', `${yellow.r},${yellow.g},${yellow.b}`, '255,255,0') +cyan = hsvToRgb(180, 1, 1) +check('hue 180 is cyan', `${cyan.r},${cyan.g},${cyan.b}`, '0,255,255') +magenta = hsvToRgb(300, 1, 1) +check('hue 300 is magenta', `${magenta.r},${magenta.g},${magenta.b}`, '255,0,255') + +// No saturation is a grey whatever the hue claims, and no value is black +// whatever else it claims. +grey = hsvToRgb(200, 0, 0.5) +check('no saturation is grey', `${grey.r},${grey.g},${grey.b}`, '128,128,128') +black = hsvToRgb(200, 1, 0) +check('no value is black', `${black.r},${black.g},${black.b}`, '0,0,0') +white = hsvToRgb(0, 0, 1) +check('no saturation at full value is white', `${white.r},${white.g},${white.b}`, '255,255,255') + +// A drag that runs off the edge of the field hands back a component slightly +// out of range; unclamped that becomes 256 and wraps to black, which reads as +// the picker breaking exactly when the pointer leaves it. +over = hsvToRgb(0, 1, 1.2) +check('an overshot value clamps rather than wraps', over.r, 255) +under = hsvToRgb(0, 1, -0.2) +check('an undershot value clamps too', under.r, 0) + // --- chamfer ------------------------------------------------------------------------ console.log('') diff --git a/tools/lint.py b/tools/lint.py index a4b06c7..825b530 100755 --- a/tools/lint.py +++ b/tools/lint.py @@ -151,31 +151,45 @@ def match_call_args(text, start): print(f"shadow {path} method `{name}()` shadows the import bound to the same name") problems += 1 -# --- locals: a variable named the same as a method it then calls ------------ +# --- locals: a variable named the same as ANY method of its own class -------- # -# Ghost resolves `this.name()` through the enclosing scope before it reaches the -# class, so a local called `name` in the same method turns the call into an -# attempt to invoke a number. It raises only when that line runs, which for a -# paint method means the first frame that draws the widget - and it shipped -# exactly that way in Scrollbar.thumbRect(). +# Ghost does not scope a method's locals to that method. Assigning `gap = 4` +# inside one method permanently replaces the method `gap()` on that object, for +# the object's lifetime, and every later call anywhere in the class raises +# "is a number, which cannot be called". +# +# Reduced, this prints 7 and then fails: +# +# class Probe { +# gap() { return 7 } +# first() { gap = 99; return gap } +# second() { return this.gap() } +# } +# p = new Probe(); p.second(); p.first(); p.second() +# +# An earlier version of this check only looked for the call in the same method +# as the local, because that is how the first instance of it presented. That +# was wrong, and the narrow rule let a second one through: a local `gap` in +# Colorbar.placePicker() broke this.gap() in Colorbar.gridRect(). The hazard is +# any local sharing a name with any method of the same class, and because the +# breakage is time-dependent - the same call works before the poisoning line +# runs and fails after - it cannot be relied on to show up in testing. for path in sorted(glob.glob('**/*.gs', recursive=True)): src = open(path).read() - # Split the file into method bodies by their opening line, so a local in one - # method is not blamed for a call in another. - starts = [(m.start(), m.group(1)) for m in re.finditer(r'^\s{2,}(\w+)\s*\([^)]*\)\s*\{', src, re.M)] + methods = set(re.findall(r'^\s{2,}([a-zA-Z_]\w*)\s*\([^)]*\)\s*\{', src, re.M)) + methods.discard('constructor') - for index, (at, method) in enumerate(starts): - end = starts[index + 1][0] if index + 1 < len(starts) else len(src) - body = src[at:end] + if not methods: + continue - assigned = set(re.findall(r'^\s+(\w+)\s*=\s*[^=]', body, re.M)) - called = set(re.findall(r'this\.(\w+)\s*\(', body)) + for m in re.finditer(r'^\s{4,}([a-zA-Z_]\w*)\s*=\s*[^=]', src, re.M): + if m.group(1) not in methods: + continue - for name in sorted(assigned & called): - line = src[:at].count('\n') + body[:body.index(name)].count('\n') + 1 - print(f"locals {path}:{line} local `{name}` shadows `this.{name}()`, called in the same method") - problems += 1 + line = src[:m.start()].count('\n') + 1 + print(f"locals {path}:{line} local `{m.group(1)}` destroys the method `{m.group(1)}()` on this object") + problems += 1 # --- palette: a theme may not invent a colour ------------------------------- # From bf2fb91a12e2de0c8f6f81ed7b1eab184ba7db83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:45:38 +0000 Subject: [PATCH 3/4] Give the timeline Aseprite's layer toggles, on rows tall enough to hold them Each layer row now carries a visibility eye and a lock, to the left of the name rather than after it - Aseprite's order, and the useful one, because the eye is what gets clicked most and belongs where the pointer already is instead of after a name of unknown length. Both are drawn dim when off rather than hidden: a control that vanishes when inactive cannot be turned back on by anyone who has not already learnt it is there. Adding them reintroduced, exactly, the fault that made the old Picotron icon sheet unusable - an icon taller than the row containing it. A 16px eye in a 12px slot drew four pixels wider than its box and overlapped the lock beside it. So a timeline row is now derived from the icon rather than from the text row, and the frame header and cell grid derive from the same number so they stay aligned with the layer rows instead of drifting by four pixels each. Knowing the shape of that bug from the last rebuild did not stop me writing it again; only rendering it did. The menus are deliberately left as File, Edit, View and Tools rather than matched to Aseprite's eight. There are no Sprite, Layer, Frame or Select commands to put in them, and a menu that opens onto nothing is worse than one that is not there. 132 assertions pass, the linter is clean, Picotron's eight tiles still match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM --- README.md | 2 +- playground.gs | 1 + resources/icons.png | Bin 1281 -> 1453 bytes shot.gs | 1 + studio/sprite/editor.gs | 2 +- studio/sprite/timeline.gs | 60 +++++++++++++++++++++++++++++++------- studio/studio.gs | 1 + tools/make-icons.py | 16 ++++++++++ 8 files changed, 70 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e29011e..75622f4 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ commands, keymap, modifiers, tools, signals, history, line drawing — runs unde with no window, and **exits non-zero on a failed assertion** so it can gate a build: ``` -ghost test.gs # 119 assertions +ghost test.gs # 132 assertions python3 tools/lint.py ``` diff --git a/playground.gs b/playground.gs index 6fa2577..61f5fdd 100644 --- a/playground.gs +++ b/playground.gs @@ -47,6 +47,7 @@ function load() { .define(['pencil', 'eraser', 'bucket', 'picker', 'select', 'move', 'line', 'rectangle']) .define(['ellipse', 'text', 'zoom', 'grid', 'layers', 'frame', 'play', 'stop']) .define(['undo', 'redo', 'save', 'open', 'plus', 'minus', 'check', 'close']) + .define(['eye', 'lock']) app.ui.cursors = new Cursors('resources/cursors.png', 8) .define('arrow', 1, 0) diff --git a/resources/icons.png b/resources/icons.png index 7f3109f9ebd2f50e050f36990b0bbe802d1b00b4..59cfa10b5a04a2b74f3b1fe37112761eb3ad9992 100644 GIT binary patch delta 1448 zcmV;Z1y}lk3atx}7k@wq1^@s6($;@}000GaNkl z0fJ(u@xR&aJYa$-T5AF$DW!z}pQdTDmVE-W{Q&)+A*Ga_-cPe76Y9Ke|GPv#9H$@+ zkL?!0hE+4Jxq=A zdsA|oHHwkSc<)=_l&{EH3w0gR{}=>fLX;ou4$b)t?XH$z(;jF3qVj7v8Ni*&-_tY? zvEZqq)S1x>CVz{Z$o##P4?w1XH;jBM9_6=80ay;FSV`HXHNh3i2S5h!Cgo=@U~%ad zxit&4Tt*8Exq3YzBy?6jz-|Dw%IESDtgF%=N;Gg-{YNQ3f>Kz><(JZ~R6YQ}9He|s z(F;_BY%SC(KOzdS*6}3e1N@r;T9hwRTb>(HrMg;1et*jp(0U)I@0U%=r(s?RZXX~* z`OUGuNB$jdUxFC#FH*T^PCx2^u5{CJdlcCM#T;R`;HJ zX#KZwd3t!jd={W~J|!!or%SG&X$`=6Bc|-tar(&+qP>CXt>BATQcBwTV}%P|Cu0oZ zaQ`M)Lx^b8BMbmt_Hnc~oes}DOC8i2lUm(TDHb;3J&VtDKYv|} zL{ff6G6G5Y0O&A?k;lFh$5q1Rq2m~agglB_QIvz~(XtylS84ljh1 zjDQ}9& zlT>Gh{gto+?sM#c9Xd!ngGO4YYewKQ0L>+GA;+MO+?Nr`=k6Wy);BYbTB9*8T#I!` zX`VH}-B{cDre_)q27^IYdVgeb6gK9a#}Td&Wk>K+U(|e>B;Y8wGHIWDk$>~;ME^nv zfZ-ees+JDA!2UuAA}j0g!DQ*^a_}DL=SmsZcXg-^yWn9!k6;!?zJ-nP1m=$uE94D2 zfZqu68bRsvaH{0bp5eclKL9^>j1`7IPp0bt0OM~1j3eOZBfSx{k-r&NEI&fN=W}`^ z2q0`H4}Swc-2X#iex2QcBYudG3v8<~(J{?HT|>etTYx?N|t%KanIr)qfp9>ksai_Nl;X6Iy>%0}#jus@%~|Y>y_jX9TNvG9|)hxMxhZ^crnp3pb#U4{A71%u4m|XSbwBH&iY;QBRCnro#gLjnul2N zR?(Zx?10IlCx5g4Z1Mrf7Vr%#-wF@;BU=F0!x1Zy-HQpXARho3z&DZKynw}}Tl7W( zwAMyT3pu%-6cQ$r53n0Rlzgr(fi)_9L&*jX(SH{C8R(^jTz#qRO7Z~!mL&2yqE`r{ zY$J%0pOFPvWqcC(0ROgt5%NVu+jA?bCu?Nok8A;>?|*Um{y0QFP4jx-&H*yWAI|kX z`tNZ25{U7>GnI>0zI)!O0i?N|tBA-VzpYSASnpW^s^i^8!}A~Zwq93Dp-sjWMqpWC zcfq=~-0H#Vj!Ts$;+y;a;KA*1osC;^Zh|iQnpz} zU?84CGK7`L(=Li!`T9(w!&`vo++KBIL9paEV1FYJs&MbscX&b_h9KqY-g^&i{$5<2 z86L3R1*nrx(a4zTk^>lu0XT2Ow7oh{KN&){H!yt_d=b`Ks}_H3aKYzfj3FG}-vrhW zGWzrp27r%FIU~U2Ul;_^ETHx24o@%*OPhRc^-(lJGADr=0fA-Zhhl)lAaEv5%z=dWxR)k$_X6Ag`z9!P zzn^9K$^bX?oF0581c7M>K~mY&G!VN(#h}gmA6*rrE|aE#voerR3<4dx-9d87UBE0E zg4_+{ecXjCQYUNyZ25y7wbre+$n}Tti+^L9hG()KJV=0eAD_MdXman~!~bU-{x+fa zVV5C`9Mbnw9^4z-w;2c~A0OC}a~$nUr^A>RX@W*`QmZ@a<-$gI@8TP#-!5iCB7eU@ zG6IQw0CX9|=wrW$nZ5mddc_V@Igq=23v}6$Ru!wEzGB07*qoM6N<$g3A4Pi2wiq diff --git a/shot.gs b/shot.gs index fc0a044..e8eeb37 100644 --- a/shot.gs +++ b/shot.gs @@ -39,6 +39,7 @@ function load() { .define(['pencil', 'eraser', 'bucket', 'picker', 'select', 'move', 'line', 'rectangle']) .define(['ellipse', 'text', 'zoom', 'grid', 'layers', 'frame', 'play', 'stop']) .define(['undo', 'redo', 'save', 'open', 'plus', 'minus', 'check', 'close']) + .define(['eye', 'lock']) app.ui.cursors = new Cursors('resources/cursors.png', 8) .define('arrow', 1, 0) diff --git a/studio/sprite/editor.gs b/studio/sprite/editor.gs index dde5bc7..28967d3 100644 --- a/studio/sprite/editor.gs +++ b/studio/sprite/editor.gs @@ -169,7 +169,7 @@ class SpriteEditor { dock.left(colours, colours.widthFor(theme)) dock.right(this.toolbar().named('tools'), theme.metric('icon') + 6) - dock.bottom(new Timeline(document).named('timeline'), theme.metric('row') * 4) + dock.bottom(new Timeline(document).named('timeline'), theme.metric('row') * 6) dock.fill(new Viewport(studio, document).named('viewport')) diff --git a/studio/sprite/timeline.gs b/studio/sprite/timeline.gs index 0208da1..082c750 100644 --- a/studio/sprite/timeline.gs +++ b/studio/sprite/timeline.gs @@ -21,11 +21,22 @@ class Timeline extends Widget { } cellWidth(ui) { - return ui.theme.metric('row') + return this.rowHeight(ui) + } + + // A timeline row carries icons, so it is at least as tall as one. + // + // The default 12px row against a 16px icon is precisely the fault that made + // the old Picotron sheet unusable - an icon taller than the row containing + // it - and it reappeared here the moment the layer toggles went in: the eye + // and the lock were drawn four pixels wider than their slots and overlapped + // into each other. + rowHeight(ui) { + return math.max(ui.theme.metric('row'), ui.theme.metric('icon')) } headerWidth(ui) { - return ui.theme.metric('row') * 5 + return this.rowHeight(ui) * 6 } frameRect(ui, index) { @@ -35,30 +46,49 @@ class Timeline extends Widget { this.bounds.x + this.headerWidth(ui) + index * size, this.bounds.y + ui.theme.metric('gutter'), size - 1, - ui.theme.metric('row') - 1 + size - 1 ) } + // The two toggles live at the left of the header, the name to their right - + // Aseprite's order, and the useful one: the eye is what gets clicked most and + // sits where the pointer already is, rather than after a name of unknown + // length. + toggleRect(ui, index, slot) { + size = this.rowHeight(ui) + + return new Rect( + this.bounds.x + ui.theme.metric('gutter') + slot * size, + this.bounds.y + size + ui.theme.metric('gutter') + index * size, + size, + size + ) + } + + togglesWidth(ui) { + return this.rowHeight(ui) * 2 + ui.theme.metric('gutter') + } + layerRect(ui, index) { - row = ui.theme.metric('row') + size = this.rowHeight(ui) + left = this.togglesWidth(ui) return new Rect( - this.bounds.x + ui.theme.metric('gutter'), - this.bounds.y + row + ui.theme.metric('gutter') + index * row, - this.headerWidth(ui) - ui.theme.metric('gutter'), - row - 1 + this.bounds.x + ui.theme.metric('gutter') + left, + this.bounds.y + size + ui.theme.metric('gutter') + index * size, + this.headerWidth(ui) - ui.theme.metric('gutter') - left, + size - 1 ) } cellRect(ui, layer, frame) { size = this.cellWidth(ui) - row = ui.theme.metric('row') return new Rect( this.bounds.x + this.headerWidth(ui) + frame * size, - this.bounds.y + row + ui.theme.metric('gutter') + layer * row, + this.bounds.y + size + ui.theme.metric('gutter') + layer * size, size - 1, - row - 1 + size - 1 ) } @@ -100,6 +130,14 @@ class Timeline extends Widget { ui.painter.textIn('body', this.layers[index], name.inset(ui.theme.metric('gutter')), 'left', 'middle', ink) + // Visible and locked, per layer. Drawn dim rather than hidden when off, + // because a control that disappears when inactive cannot be turned back + // on by anyone who has not already learnt it is there. + if (ui.icons != null) { + ui.icons.drawIn('eye', this.toggleRect(ui, index, 0), ui.theme.of('text.normal'), 1) + ui.icons.drawIn('lock', this.toggleRect(ui, index, 1), ui.theme.of('text.dim'), 1) + } + for (frame = 0; frame < this.frames; frame++) { cell = this.cellRect(ui, index, frame) diff --git a/studio/studio.gs b/studio/studio.gs index aca45d5..3cad534 100644 --- a/studio/studio.gs +++ b/studio/studio.gs @@ -72,6 +72,7 @@ class Studio { .define(['pencil', 'eraser', 'bucket', 'picker', 'select', 'move', 'line', 'rectangle']) .define(['ellipse', 'text', 'zoom', 'grid', 'layers', 'frame', 'play', 'stop']) .define(['undo', 'redo', 'save', 'open', 'plus', 'minus', 'check', 'close']) + .define(['eye', 'lock']) // Picotron's pointer is a hollow outline rather than a filled arrow with a // border, so every interior pixel is whatever is behind it. Hotspots come diff --git a/tools/make-icons.py b/tools/make-icons.py index a802071..9b00aba 100755 --- a/tools/make-icons.py +++ b/tools/make-icons.py @@ -241,6 +241,22 @@ "..###......###..", "..##........##..", "................", "................", ], + "eye": [ + "................", "................", "................", + ".....######.....", "...##++++++##...", "..#++++##++++#..", + ".#+++#++++#+++#.", "#++++#++++#++++#", ".#+++#++++#+++#.", + "..#++++##++++#..", "...##++++++##...", ".....######.....", + "................", "................", "................", + "................", + ], + "lock": [ + "................", ".....######.....", "....##++++##....", + "...##+....+##...", "...#+......+#...", "...#+......+#...", + "..############..", "..#++++++++++#..", "..#+++####+++#..", + "..#+++#..#+++#..", "..#+++####+++#..", "..#++++##++++#..", + "..#++++##++++#..", "..############..", "................", + "................", + ], } # --- file icons: full colour, 15x16, as measured off Picotron ----------------- From 0509c7dff0dfa83efd177dc301b1db201ddcfe5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:55:12 +0000 Subject: [PATCH 4/4] Make CI prove the icon art regenerates from its source tools/make-icons.py holds the art as ASCII so that changing an icon is a readable diff rather than a binary blob nobody can review, and the PNGs under resources/ are build output. That is only true while the two agree. A hand-edited PNG would quietly become the real source and leave the ASCII a decorative lie that still looks authoritative in review. So CI regenerates and fails on any difference. Verified by committing a PNG with one pixel poked and watching the check go red - the first attempt at that test was wrong, because poking the working file and regenerating simply overwrote the poke, which proves nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f7e037..1938cd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,18 @@ jobs: - name: Lint run: python3 tools/lint.py + # The PNGs under resources/ are build output: tools/make-icons.py holds + # the art as ASCII so that changing an icon is a readable diff rather + # than a binary blob nobody can review. That claim is only true while the + # two agree, so regenerate them and fail on any difference - a hand-edited + # PNG would otherwise silently become the real source and the ASCII a + # decorative lie. + - name: Icon art regenerates byte for byte + run: | + python3 -m pip install --quiet pillow + python3 tools/make-icons.py + git diff --exit-code -- resources/ + # Every source file has to at least parse. `ghost ` reports a syntax # fault before it evaluates anything, so a file that only fails on a # `lumen:` import is fine - a file that fails to parse is not.