From e9a9a94fefcac32daecc154b63f3f5571799fbb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:34:34 +0000 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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. From 4b2e769405973f4ea10dc2c07798aa33d69f6c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:11:56 +0000 Subject: [PATCH 5/5] Bring the tutorial up to the interface that exists, and check its code in CI The page had spent two rebuilds describing an interface the repository no longer had: bevelled surfaces with a highlight-and-shadow pair, circular corner profiles, free hex colour per theme, and rendering at the window's own resolution. It carried a notice saying so, which is better than lying quietly but is not a fix. The teaching change is the painter's vocabulary. The old chapter taught a bevel - a light line on the top and left, a dark one on the bottom and right, swapped to make the same box read as a hole - and called it "the entire 3D vocabulary of this interface". It is the obvious way to build a retro interface and it is not what Aseprite does: a button there is a flat fill with one pixel off each corner and no border of any kind, and only windows are outlined. So the chapter now teaches chamfer, surface, and a tone ramp, and says plainly that the earlier version was wrong and how you would tell. The theme chapter follows: colours come from a named Catppuccin lookup rather than from anyone's judgement about what shade a toolbar should be, which is worth a paragraph because the reference screenshots were lossy enough that the palette had to be identified rather than measured. Part 00 now teaches the framebuffer, since it is the decision everything else rests on and a 12px menu bar means nothing without it. There is a new Part 09: the playground. Eight parts in, the old order had the reader build widgets and not look at one until Studio arrived at part 10, which is exactly backwards - every serious fault in this project was found by rendering something and measuring pixels, never by reasoning about code. That part builds the gallery entry point and the headless screenshot flag, and lists three faults that were invisible until a gallery existed to show them. The papercut log gains the one this rebuild found, which is worse than the import-shadowing entry beside it: a local does not shadow a same-named method, it destroys that method on the object permanently, from any other method. It is time-dependent, so whether a test catches it depends on call order. It shipped twice - the second time after a linter had been written for it, because the first version of that check only looked in the same method as the local. And the page's own mockup of the workspace is flat Catppuccin now, with no inset highlight anywhere in it. tools/check-tutorial.py extracts every whole-file code block and parses it, and CI runs it. A tutorial whose premise is "type this" fails worst when the code has quietly stopped working, because the reader cannot tell whether they mistyped it or the page is wrong. Prose going stale is hard to catch automatically; code going stale is not, and this catches that half. All 38 blocks parse; verified against a deliberately broken one. 132 assertions, a clean linter, all eight pixel tiles matching. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019QF46RxyogNPLNX7DajyKM --- .github/workflows/ci.yml | 9 + README.md | 10 +- docs/tutorial.html | 702 ++++++++++++++++++++++++++++++--------- tools/check-tutorial.py | 83 +++++ 4 files changed, 644 insertions(+), 160 deletions(-) create mode 100755 tools/check-tutorial.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1938cd9..2698527 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,15 @@ jobs: python3 tools/make-icons.py git diff --exit-code -- resources/ + # The tutorial tells a reader to type out whole files. If one stops + # parsing they find out by typing it in and getting a syntax error, with + # no way to tell whether they mistyped it or the page is wrong - the worst + # failure available to a document whose premise is "type this". The page + # spent two rebuilds describing an interface the code no longer had; prose + # going stale is hard to catch, code going stale is not. + - name: The tutorial's code still parses + run: python3 tools/check-tutorial.py + # 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. diff --git a/README.md b/README.md index 75622f4..610f6a3 100644 --- a/README.md +++ b/README.md @@ -286,9 +286,7 @@ Lumen's audio API exposes no samples and no playhead). [docs/picotron.md](docs/picotron.md) is the current spec: every measurement the interface is built against, how they were taken, and which of them are exact. -[docs/tutorial.html](docs/tutorial.html) builds the repository from an empty folder, -explaining the reasoning as it goes — **but it documents the interface as it was before -the Picotron rebuild**, and its chapters on painting and theming are now wrong. The -structural half (the widget tree, the dock, input capture, commands, the keymap) and the -Ghost papercut reference are still accurate. Rewriting it against the current design is -outstanding work. +[docs/tutorial.html](docs/tutorial.html) builds the repository from an empty folder, in +order, explaining the reasoning as it goes. Open it in a browser. Its code blocks are +checked in CI: every file the reader is told to type is parsed on every commit, because +a tutorial whose code has quietly stopped working is worse than no tutorial. diff --git a/docs/tutorial.html b/docs/tutorial.html index d47885f..0675a5f 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -403,48 +403,49 @@ padding: 4px 9px; margin-bottom: 18px; } -/* --- workspace mock ------------------------------------------------------ */ +/* --- workspace mock ------------------------------------------------------ + Catppuccin Mocha, hard-coded, because this depicts one specific product + rather than participating in this page's own light/dark theming. Flat fills + and single-pixel rules only: there is not an inset highlight anywhere below, + which is the whole difference between this and the interface it replaced. */ .mock { - border: 1px solid var(--bevel-dark); - background: var(--surface); + --m-base: #1e1e2e; --m-mantle: #181825; + --m-s0: #313244; --m-s1: #45475a; --m-s2: #585b70; + --m-text: #cdd6f4; --m-dim: #7f849c; --m-accent: #89b4fa; + + border: 1px solid var(--m-mantle); + background: var(--m-base); box-shadow: 0 0 0 1px var(--line); font-family: "IBM Plex Mono", monospace; - font-size: 10px; color: var(--ink-soft); + font-size: 10px; color: var(--m-dim); user-select: none; max-width: 920px; margin: 0 0 12px; } .mock .bar { display: flex; align-items: center; gap: 14px; - padding: 3px 8px; background: var(--raised); - border-bottom: 1px solid var(--bevel-dark); - box-shadow: inset 0 1px 0 var(--bevel-light); -} -.mock .bar.title { background: var(--teal); color: #fff; font-weight: 600; letter-spacing: 0.05em; } -.mock .tab { - padding: 2px 12px; background: var(--surface); - border: 1px solid var(--bevel-dark); border-bottom: 0; - box-shadow: inset 1px 1px 0 var(--bevel-light); margin-bottom: -1px; + padding: 3px 8px; background: var(--m-s0); color: var(--m-text); } -.mock .tab.on { background: var(--select); color: var(--select-ink); box-shadow: none; } +.mock .bar.title { background: var(--m-s1); color: var(--m-text); font-weight: 600; letter-spacing: 0.05em; } +.mock .tab { padding: 2px 12px; background: var(--m-s0); color: var(--m-dim); } +.mock .tab.on { background: var(--m-s2); color: var(--m-text); } .mock .body { display: grid; grid-template-columns: 68px minmax(0,1fr) 30px; } -.mock .col { border-right: 1px solid var(--bevel-dark); padding: 6px; display: flex; flex-direction: column; gap: 6px; } -.mock .col.right { border-right: 0; border-left: 1px solid var(--bevel-dark); align-items: center; gap: 3px; padding: 5px 3px; } +.mock .col { background: var(--m-s0); padding: 6px; display: flex; flex-direction: column; gap: 6px; } +.mock .col.right { align-items: center; gap: 3px; padding: 5px 3px; } .mock .swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; } -.mock .swatches i { display: block; aspect-ratio: 1; border: 1px solid var(--bevel-dark); } +.mock .swatches i { display: block; aspect-ratio: 1; border: 1px solid var(--m-mantle); } +/* A tool button carries no fill at all until it is selected - which is what + Aseprite's toolbars actually do. */ .mock .btn { - width: 20px; height: 20px; background: var(--raised); - border: 1px solid var(--bevel-dark); - box-shadow: inset 1px 1px 0 var(--bevel-light); - display: grid; place-items: center; font-size: 9px; color: var(--ink); + width: 20px; height: 20px; background: transparent; + display: grid; place-items: center; font-size: 9px; color: var(--m-text); } -.mock .btn.on { background: var(--select); color: var(--select-ink); box-shadow: inset 1px 1px 0 rgba(255,255,255,.35); } +.mock .btn.on { background: var(--m-s2); } .mock .canvas { min-height: 186px; - background: repeating-conic-gradient(var(--sunken) 0% 25%, var(--well) 0% 50%) 0 0 / 12px 12px; - box-shadow: inset 1px 1px 0 var(--bevel-dark); + background: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%) 0 0 / 12px 12px; display: grid; place-items: center; padding: 18px; } -.mock .sprite { width: 108px; height: 108px; border: 1px solid var(--bevel-dark); background: var(--accent); position: relative; } +.mock .sprite { width: 108px; height: 108px; border: 1px solid var(--m-mantle); background: var(--m-accent); position: relative; } .mock .sprite::after { content: ""; position: absolute; inset: 0; background-image: @@ -452,14 +453,13 @@ linear-gradient(to bottom, rgba(0,0,0,.14) 1px, transparent 1px); background-size: 12px 12px; } -.mock .timeline { border-top: 1px solid var(--bevel-dark); background: var(--surface); padding: 4px 6px; display: flex; flex-direction: column; gap: 3px; } +.mock .timeline { background: var(--m-s0); padding: 4px 6px; display: flex; flex-direction: column; gap: 3px; } .mock .frames { display: flex; gap: 2px; } -.mock .frames span { width: 18px; height: 12px; border: 1px solid var(--bevel-dark); background: var(--well); } -.mock .frames span.on { background: var(--select); } +.mock .frames span { width: 18px; height: 12px; background: var(--m-mantle); } +.mock .frames span.on { background: var(--m-s2); } .mock .status { display: flex; justify-content: space-between; - border-top: 1px solid var(--bevel-dark); background: var(--raised); - padding: 3px 8px; box-shadow: inset 0 1px 0 var(--bevel-light); + background: var(--m-s0); color: var(--m-dim); padding: 3px 8px; } .mock-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); @@ -492,17 +492,7 @@ @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } - .stale-notice { - margin: 1.6rem 0 2rem; - padding: 1rem 1.15rem; - border-left: 4px solid #d08b2a; - background: rgba(208, 139, 42, 0.09); - border-radius: 0 6px 6px 0; - font-size: 0.95rem; - line-height: 1.6; - } - .stale-notice b { color: #d08b2a; }
@@ -520,14 +510,15 @@

Build order

  • 06Widget
  • 07Ui and Pointer
  • 08Button
  • -
  • 09Dock
  • -
  • 10Studio
  • -
  • 11Commands, keymap
  • -
  • 12Editor chrome
  • -
  • 13Document, viewport
  • -
  • 14The map editor
  • -
  • 15Room for sound
  • -
  • 16Ship it
  • +
  • 09The playground
  • +
  • 10Dock
  • +
  • 11Studio
  • +
  • 12Commands, keymap
  • +
  • 13Editor chrome
  • +
  • 14Document, viewport
  • +
  • 15The map editor
  • +
  • 16Room for sound
  • +
  • 17Ship it
  • Reference

      @@ -548,21 +539,11 @@

      Chisel & Studio

      eventually — a sound editor.

      -
      - This document is out of date, and knowingly so. - It builds the interface Studio had before it was rebuilt on - Picotron: bevelled surfaces with a highlight-and-shadow - pair, circular corner profiles, a 16px icon grid, free hex colour per theme, and - rendering at the window's own resolution. The code now does none of those things — - it draws flat fills into a magnified 480×270 framebuffer, cuts its corners rather - than rounding them, outlines windows but not controls, and indexes every colour to - a fixed palette. -

      - The reasoning about structure — the widget tree, the dock, capture-based - input, commands and the keymap, and every Ghost papercut in the reference section — - is still accurate and still worth reading. The chapters on painting and theming are - not. docs/picotron.md is the current spec. -
      +

      + The interface is measured rather than invented: every metric comes off a real Aseprite + screenshot, every colour off the published Catppuccin palette, and a pixel-comparison tool in + the repository holds the result against the references on every commit. +

      Layers
      chisel → studio → editors
      @@ -674,6 +655,42 @@

      Part 00Setup, and a window on screen

      │ └── sound/ the sound editor, later └── resources/ fonts, icons
    +

    + One file before the entry point, because the very first line of load() needs it. + This is the whole of the framebuffer decision, and it is nine lines: +

    + +
    +
    chisel/support/logical-size.gsnew
    +
    import "ghost:math"
    +
    +// The framebuffer to draw into, given a real window.
    +//
    +// `target` is the logical height the design was drawn for - 540 for Aseprite's
    +// chrome. Returns the integer magnification that puts the window nearest that,
    +// and the window divided by it.
    +function logicalSize(width, height, preferred = null, target = 540) {
    +  scale = preferred
    +
    +  if (scale == null) {
    +    scale = math.floor((height / (target * 1.0)) + 0.5)
    +  }
    +
    +  scale = math.floor(scale)
    +
    +  if (scale < 1) {
    +    scale = 1
    +  }
    +
    +  // A window smaller than one magnification would otherwise divide to nothing.
    +  return {
    +    w: math.max(1, math.floor(width / scale)),
    +    h: math.max(1, math.floor(height / scale)),
    +    scale: scale
    +  }
    +}
    +
    +

    Lumen calls load() once, then update(dt) and draw() every frame, and hands input to optional callbacks. That is the entire contract. Type this: @@ -684,19 +701,51 @@

    Part 00Setup, and a window on screen

    import "lumen:window"
     import "lumen:canvas"
     import "lumen:color"
    +import { logicalSize } from "chisel/support/logical-size"
     
     function load() {
       window.setTitle('Studio')
    -  window.setMode(1280, 800)
    +  window.setMode(1440, 900)
       window.setResizable(true)
       window.setVsync(true)
    +
    +  // Draw into a small framebuffer and let the engine magnify the whole frame.
    +  // A 12px menu bar and a 16x16 icon only read correctly if one drawn pixel
    +  // covers several screen pixels; at the window's own resolution they are not
    +  // chunky, they are microscopic.
    +  frame = logicalSize(1440, 900)
    +
    +  window.setLogicalSize(frame.w, frame.h)
    +  window.setPixelPerfect(true)
     }
     
     function draw() {
    -  canvas.clear(color.hex('#2c2c34'))
    +  canvas.clear(color.hex('#1e1e2e'))
     }
    +
    +

    + That framebuffer is the decision everything else rests on, so it is worth being + precise about now rather than discovering later. Aseprite's chrome is drawn for a 960×540 grid + and magnified twice on screen. Its measurements — a 12px menu bar, an 11px tab, 15px tool slots + — are meaningless anywhere else: at 1× on a modern display they are a few millimetres tall. +

    +

    + The magnification is picked rather than fixed. logicalSize() takes the height the + design was drawn for, finds the integer magnification that puts the window nearest it, and + divides. 1920×1080 gives exactly 960×540 at 2×; 1440×900 gives 720×450 at the same 2×. A wider + monitor therefore buys workspace rather than bigger chrome, which matters because this + is an editor inside a window rather than an operating system that owns the screen. +

    +

    + Whole numbers only, and this is the one rule that cannot bend: a fractional magnification draws + some source pixels two screen pixels wide and their neighbours three. That is precisely how + pixel art comes out looking uneven, and a program for making pixel art cannot be the thing that + does it. +

    +
    +
    ▶ Run it
    @@ -785,7 +834,7 @@

    The four objects everything else is made of

    Before any widget exists we need four things, and they are the next four parts: a Rect (where), a Theme (what colour), a - Painter (how to draw a bevel), and a Widget (a node in the tree). + Painter (how to draw a surface), and a Widget (a node in the tree). Then a Ui to own the tree and the input state. Six files and you have a working toolkit; everything after is widgets.

    @@ -944,11 +993,11 @@

    Why splitTop returns a list of two

    function draw() { // That semicolon is not optional — see the note below. - canvas.clear(color.hex('#2c2c34')); + canvas.clear(color.hex('#1e1e2e')); [bar, rest] = new Rect(0, 0, window.width, window.height).splitTop(40) - canvas.setColor(color.hex('#3a3a44')) + canvas.setColor(color.hex('#313244')) canvas.filledRectangle(bar.x, bar.y, bar.w, bar.h) canvas.setColor(color.hex('#191a1e')) @@ -978,7 +1027,7 @@

    Part 03Theme — every colour in one object

    A UI kit lives or dies on whether a colour can be changed in one place. The rule: widgets ask for roles, never shades. theme.of('button.face') survives a theme swap; - color.hex('#3a3a44') sprayed through forty files does not. + color.hex('#313244') sprayed through forty files does not.

    @@ -1148,33 +1197,81 @@

    A method's own name shadows a same-named import — for every method in its

    -
    chisel/themes/aseprite-dark.gsnew
    +
    chisel/themes/aseprite-mocha.gsnew
    import "lumen:color"
     import { Theme } from "chisel/theme"
    -
    -// Three greys, two bevel lines, one saturated selection. The artwork is the
    -// only thing on screen allowed to be loud; the chrome recedes on purpose.
    -function asepriteDark() {
    -  return new Theme('aseprite.dark').set({
    -    'window.face':     color.hex('#2c2c34'),
    -    'panel.face':      color.hex('#3a3a44'),
    -    'panel.well':      color.hex('#191a1e'),
    -    'bevel.light':     color.hex('#54545f'),
    -    'bevel.dark':      color.hex('#121216'),
    -    'button.face':     color.hex('#3a3a44'),
    -    'button.hover':    color.hex('#474751'),
    -    'button.pressed':  color.hex('#2a2a32'),
    -    'button.selected': color.hex('#4f7bb5'),
    -    'text.normal':     color.hex('#e9e6e1'),
    -    'text.dim':        color.hex('#948f9c'),
    -    'text.selected':   color.hex('#ffffff'),
    -    'accent':          color.hex('#f2a340'),
    -    'checker.light':   color.hex('#6b6b6b'),
    -    'checker.dark':    color.hex('#535353')
    +import { catppuccinMocha } from "chisel/support/catppuccin"
    +
    +// Every colour comes from a named lookup, never a literal. That indirection is
    +// the whole point: a hex two off a real palette entry looks fine on its own and
    +// wrong beside everything else, and forty of those that only nearly agree is
    +// most of what "this interface looks messy" turns out to mean.
    +function asepriteMocha() {
    +  palette = catppuccinMocha()
    +
    +  theme = new Theme('aseprite.mocha').set({
    +    // The workspace is `base`; every bar on it is `surface0`. That one step is
    +    // the whole separation between chrome and canvas - there are no outlines
    +    // doing that job.
    +    '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),
    +
    +    // A control climbs the surface ramp as things happen to it. Measured: the
    +    // selected tool sits on surface2 while its neighbours sit on the bar.
    +    '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),
    +
    +    // Aseprite's own, not the theme's. Both the light and the dark reference
    +    // show the same two greys, which is how you can tell the application paints
    +    // the checkerboard rather than letting a theme near it.
    +    'checker.light':   color.hex('#c0c0c0'),
    +    'checker.dark':    color.hex('#808080')
    +  }).sized({
    +    row: 12, bar: 12, tab: 11, tool: 15, icon: 16,
    +    radius: 2,   // a window's corner
    +    cut: 1       // a control's
       })
    +
    +  theme.native = 12
    +
    +  return theme
     }
    +
    +

    + The colours are identified, not chosen. The reference screenshots are Aseprite + themed with Catppuccin, whose values are published — so + this file does not contain a single judgement about what shade of grey a toolbar should be. +

    +

    + That mattered more than expected. The screenshots arrived as lossy WebP: thirty thousand colours + in a picture of an interface that uses about thirty. Nothing could be recovered from that by + measurement, because the damage is not a function — the same true colour comes back differently + depending on what surrounds it. But five Catppuccin values survive the compression at + distance nought, which is enough to identify the palette; and once you know which + palette it is, you look the rest up rather than measuring them. What was uncertain was the name, + not the numbers. +

    +

    + tools/lint.py enforces it: a raw hex in a theme that claims a palette fails the + build. The two exceptions are listed by name, and both are measured facts rather than taste. +

    +
    +

    The trap this design avoids — and it is a nasty one

    @@ -1210,25 +1307,57 @@

    Part 04Painter — the only file that touches the c an off-screen thumbnail you hand a widget a different painter and nothing else changes.

    +
    +

    + There is no 3D vocabulary here, and that is the whole point. An earlier version + of this chapter taught a bevel — a light line on the top and left, a dark one on the + bottom and right, swapped to make the same box read as a hole. It is the obvious way to build a + retro interface and it is not what Aseprite does. +

    +

    + Measured off two of its controls: a button is a flat #c2c3c7 fill on the + #fff1e8 body with one pixel off each corner and no border of any kind; a + tab strip is a flat #83769c. Only windows are outlined. Putting a one-pixel line + around forty controls that the real thing leaves as flat tone against flat tone reads as heavy + and busy without any single control being identifiably wrong — which is exactly why it can + survive a long time unnoticed, and why this chapter was rewritten. +

    +
    +

    The six, from first principles:

      -
    • fill — a solid rectangle. Everything else is edges on top of one of these.
    • -
    • bevel — one light line on the top and left, one dark line on the bottom and - right. Swap them and the same box reads as a hole. That is the entire 3D vocabulary of this - interface; there are no shadows and no gradients.
    • -
    • panel — fill plus a raised bevel. The most-called function in the kit.
    • -
    • well — fill plus a sunken bevel: the canvas surround, a text field, a list.
    • -
    • groove — a dark line with a light line under it. This separator does more for - the "real tool" feeling than any other single detail.
    • +
    • fill — a solid rectangle. Everything else is a shape cut out of one of these.
    • +
    • chamfered — a fill with its corners cut on a 45° diagonal rather than rounded. + Two pixels off a window's first row and one off its second; one pixel on a control. A cut, not a + curve: at this size a circular quadrant and a straight diagonal differ by a pixel or two, and + those are the pixels a reader compares against a screenshot.
    • +
    • surface — a chamfered fill inside a one-pixel outline, the inner shape + deflated by one and cut one less deeply so the border stays even around the corner. This is a + window. It is not a button.
    • +
    • raised / sunk — a control: a flat chamfered fill, no outline. + They differ by their fill alone rather than by which edge catches the light. That sounds like a + loss of vocabulary and is not — with a tone ramp behind it, pressed is one step darker and + hovered one step lighter, which reads better at these sizes than a one-pixel highlight ever did.
    • +
    • well — the same construction with a different fill: the canvas surround, a + text field, a list. A well is not an inverted button here, it is a surface in a lower tone.
    • text — placed on whole pixels, aligned inside a rect rather than positioned - by hand.
    • + by hand, and centred on the cap band rather than the em box.
    +

    + groove survives as a single line rather than a pair. A dark rule with a light one + under it does more for the "real tool" feeling than any other detail in a bevelled interface — and + at a 12px row in a flat one it reads as a thick black bar. The same three pixels, exactly wrong in + the new context. +

    +
    chisel/painter.gsnew
    import "lumen:canvas"
    +import { Rect } from "chisel/geometry/rect"
     import { snap } from "chisel/support/snap"
    +import { chamfer } from "chisel/support/chamfer"
     
     class Painter {
       constructor(theme) {
    @@ -1256,38 +1385,114 @@ 

    Part 04Painter — the only file that touches the c canvas.line(snap(x), snap(y), snap(x), snap(y + height)) } - bevel(rect, raised) { - light = this.theme.of('bevel.light') - dark = this.theme.of('bevel.dark') + // A fill with its corners cut. `cut` is how many pixels come off the first + // row; each row after that takes one fewer, which is a 45-degree diagonal. + // + // `corners` is [topLeft, topRight, bottomRight, bottomLeft]. A title bar + // cuts its top two and leaves the bottom square, because cutting all four + // leaves a notch of the body showing through at each end. + chamfered(rect, paint, cut, corners = null) { + if (cut < 1) { + return this.fill(rect, paint) + } + + if (corners == null) { + corners = [true, true, true, true] + } - if (!raised) { - light = this.theme.of('bevel.dark') - dark = this.theme.of('bevel.light') + insets = chamfer(cut) + + this.fill(new Rect(rect.x, rect.y + cut, rect.w, rect.h - cut * 2), paint) + + for (step = 0; step < cut; step++) { + inset = insets[step] + + left = 0 + right = 0 + + if (corners[0]) { left = inset } + if (corners[1]) { right = inset } + + this.fill(new Rect(rect.x + left, rect.y + step, rect.w - left - right, 1), paint) + + left = 0 + right = 0 + + if (corners[3]) { left = inset } + if (corners[2]) { right = inset } + + this.fill(new Rect(rect.x + left, rect.bottom() - 1 - step, rect.w - left - right, 1), paint) + } + } + + // A window: a flat fill inside a one-pixel outline. The inner shape is + // deflated by one and cut one less deeply, which is what keeps the border an + // even pixel wide as it goes round the corner. + surface(rect, fill, edge, cut, corners = null) { + this.chamfered(rect, edge, cut, corners) + this.chamfered(rect.inset(1), fill, cut - 1, corners) + } + + // The corner a control takes, which is not the one a window takes: + // measured at one pixel against a window's two. + controlCut() { + return this.theme.metric('cut') + } + + // A control. A flat chamfered fill and NO OUTLINE - measured, not + // simplified. Only windows are outlined. + raised(rect, face = null, corners = null) { + if (face == null) { + face = this.theme.of('button.face') + } + + this.chamfered(rect, face, this.controlCut(), corners) + } + + // A pressed control. Same shape, darker fill. There is no bevel to invert. + sunk(rect, face = null, corners = null) { + if (face == null) { + face = this.theme.of('button.pressed') } - this.hline(rect.x, rect.y, rect.w - 1, light) - this.vline(rect.x, rect.y, rect.h - 1, light) - this.hline(rect.x, rect.bottom() - 1, rect.w - 1, dark) - this.vline(rect.right() - 1, rect.y, rect.h - 1, dark) + this.chamfered(rect, face, this.controlCut(), corners) } - panel(rect, face) { + // A bar or a card. Panels are the ground rather than objects on it, so they + // are square and unoutlined: a bar that meets the window edge has nothing to + // be outlined against. + panel(rect, face = null) { if (face == null) { face = this.theme.of('panel.face') } this.fill(rect, face) - this.bevel(rect, true) } - well(rect) { - this.fill(rect, this.theme.of('panel.well')) - this.bevel(rect, false) + // A hole: the canvas surround, a text field, a list, a scroll track. + well(rect, face = null, corners = null) { + if (face == null) { + face = this.theme.of('panel.well') + } + + this.chamfered(rect, face, this.controlCut(), corners) + } + + // A small square control that IS outlined - the exception, and a measured + // one. A checkbox is 9x9 with a 1px border: it earns the border by being + // small, where a 9px flat fill one tone from its ground is a smudge and a + // 40px button is plainly a button. + boxed(rect, fill, edge = null) { + if (edge == null) { + edge = this.theme.of('outline') + } + + this.surface(rect, fill, edge, 0) } + // One line, not two. groove(x, y, width) { - this.hline(x, y, width, this.theme.of('bevel.dark')) - this.hline(x, y + 1, width, this.theme.of('bevel.light')) + this.hline(x, y, width, this.theme.of('outline')) } // ---- text ----------------------------------------------------------- @@ -1359,7 +1564,7 @@

    Part 04Painter — the only file that touches the c import "lumen:canvas" import { Rect } from "chisel/geometry/rect" import { Painter } from "chisel/painter" -import { asepriteDark } from "chisel/themes/aseprite-dark" +import { asepriteMocha } from "chisel/themes/aseprite-mocha" app = {} @@ -1368,7 +1573,7 @@

    Part 04Painter — the only file that touches the c window.setMode(1280, 800) window.setResizable(true) - app.theme = asepriteDark().useScale(1).loadFonts(null) + app.theme = asepriteMocha().useScale(1).loadFonts(null) app.painter = new Painter(app.theme) } @@ -1386,8 +1591,9 @@

    Part 04Painter — the only file that touches the c painter.groove(rest.x + 24, rest.y + 60, 200) }

    - A raised menu bar with a label, a sunken well, and a groove. It already looks like a tool - rather than a game — that is the bevel doing all the work. (A bare app map at + A bar with a label, a well, and a rule between them. It already looks like a tool rather than + a game, and nothing is drawing a highlight to achieve that — it is one tone step between the + bar and the ground, which is the entire trick. (A bare app map at the top of main.gs is the one place module-level state is fine: it is mutated through .set-style property assignment on an object, never rebound.)

    @@ -2017,7 +2223,7 @@

    and / or do not short-circuit — and this file ju import { Ui } from "chisel/ui" import { Painter } from "chisel/painter" import { Panel } from "chisel/widgets/panel" -import { asepriteDark } from "chisel/themes/aseprite-dark" +import { asepriteMocha } from "chisel/themes/aseprite-mocha" // The one map of module-level state in the program. It is only ever mutated // through property assignment (app.ui = ...), which does work — unlike @@ -2030,7 +2236,7 @@

    and / or do not short-circuit — and this file ju window.setResizable(true) window.setVsync(true) - theme = asepriteDark().useScale(1).loadFonts(null) + theme = asepriteMocha().useScale(1).loadFonts(null) app.ui = new Ui(theme, new Painter(theme)) app.ui.mount(new Panel('hello').well()) @@ -2066,9 +2272,16 @@

    Part 08Button — five states and the slide-off rul

    Aseprite's controls have exactly five appearances, and nothing in between: normal, hover, - pressed, selected, disabled. Hover lightens by one step, pressed inverts the bevel, - selected takes the accent, disabled dims the label and keeps the face. No transitions, no - animation — the state is the feedback. + pressed, selected, disabled. Every one of them is a step on a tone ramp: hover is + one lighter, pressed one darker, selected further along again, disabled dims the label and keeps + the face. No transitions, no animation — the state is the feedback. +

    + +

    + With no bevel to invert, that ramp is doing all the work, and it turns out to read better at these + sizes than a one-pixel highlight ever did. It also imposes a rule that the bevelled version could + ignore: a control is read entirely by its tone against its ground, so there must always be + a step between them. Get that wrong and the control does not look flat, it looks absent.

    @@ -2119,13 +2332,24 @@

    Part 08Button — five states and the slide-off rul paint(ui) { painter = ui.painter - // Pressed inverts the bevel and nudges the contents one pixel down and + // Pressed takes a darker fill and nudges the contents one pixel down and // right. That single pixel is the whole animation budget of this // interface, and it is enough. raised = !ui.isActive(this) - painter.fill(this.bounds, this.face(ui)) - painter.bevel(this.bounds, raised) + // A toolbar item draws no fill at all until something happens to it. That + // is not a style choice - Aseprite's toolbars carry bare icons with no + // button shape behind them, and a filled button only ever appears on the + // light body. Modelling both is what lets one button.face serve the whole + // interface: without it, a fill that reads on a grey bar disappears on a + // white one and the other way round. + if (this.filled(ui)) { + if (raised) { + painter.raised(this.bounds, this.face(ui)) + } else { + painter.sunk(this.bounds, this.face(ui)) + } + } body = this.bounds @@ -2188,7 +2412,7 @@

    Part 08Button — five states and the slide-off rul app.ui.mount(root)

    - Hover it: one step lighter. Hold it: the bevel inverts. Hold, slide off, release: nothing + Hover it: one step lighter. Hold it: one step darker. Hold, slide off, release: nothing happens — that is the rule most hand-rolled UIs get wrong. Dwell on it for half a second and the tooltip appears with its shortcut.

    @@ -2203,7 +2427,139 @@

    Part 08Button — five states and the slide-off rul
    -

    Part 09Dock — carving the workspace

    +

    Part 09The playground — see what you have built

    + +

    + Eight parts in and nothing has been looked at. That is the wrong way round, and it is worth fixing + before another widget is written, because every serious fault in this project was found by + rendering something and measuring the pixels — never by reasoning about the code. A test + suite cannot see a one-pixel fringe. A linter cannot see an icon that is taller than the row + containing it. You have to draw it. +

    + +

    + So: a second entry point that mounts nothing but widgets. No Studio, no documents, no editors — the + framework has to stand up on its own before an application leans on it. +

    + +
    +
    playground.gsnew
    +
    import "lumen:window"
    +import { Ui } from "chisel/ui"
    +import { Painter } from "chisel/painter"
    +import { asepriteMocha } from "chisel/themes/aseprite-mocha"
    +import { logicalSize } from "chisel/support/logical-size"
    +import { Gallery } from "playground/gallery"
    +
    +app = {}
    +
    +function load() {
    +  window.setTitle('Chisel - widget playground')
    +  window.setMode(1440, 900)
    +  window.setResizable(true)
    +
    +  frame = logicalSize(1440, 900)
    +
    +  window.setLogicalSize(frame.w, frame.h)
    +  window.setPixelPerfect(true)
    +
    +  // Scale 1: the framebuffer does the magnifying, so every metric is used at
    +  // the size it was measured at.
    +  theme = asepriteMocha().useScale(1).loadFonts(null)
    +
    +  app.ui = new Ui(theme, new Painter(theme))
    +  app.ui.mount(new Gallery(theme))
    +}
    +
    +function update(dt) { app.ui.tick(dt) }
    +function draw()     { app.ui.paint() }
    +
    +// Lumen reports the resize in real window pixels; everything above works in
    +// framebuffer pixels. Recomputing the magnification here is what makes a wider
    +// window buy workspace rather than bigger chrome.
    +function resize(width, height) {
    +  frame = logicalSize(width, height)
    +
    +  window.setLogicalSize(frame.w, frame.h)
    +
    +  return app.ui.resized(frame.w, frame.h)
    +}
    +
    +function mousepressed(x, y, button, clicks) { app.ui.pressed(x, y, button, clicks) }
    +function mousereleased(x, y, button)        { app.ui.released(x, y, button) }
    +function mousemoved(x, y, dx, dy)           { app.ui.moved(x, y, dx, dy) }
    +
    + +
    +

    lumen playground.gs — every control you own, on one screen.

    +
    + +

    + The gallery itself is a plain widget holding cards of controls; there is nothing to teach in it + beyond what Part 06 already covered, so it lives in the repository rather than on this page. What + matters is the habit. +

    + +
    +

    A gallery is the only place a design fault is visible

    +

    + A control looks fine on its own and wrong beside twenty others. Every one of these was invisible + until the gallery was rendered, and none of them would have failed a test: +

    +
      +
    • Heading text drawn in the colour used for title bars — perfectly legible as a large flat + area, and invisible as text on toolbar grey.
    • +
    • Buttons that vanished entirely, because removing their outlines left the fill matching the + panel beneath it. Correct code; nothing to see.
    • +
    • Palette swatches with a one-pixel fringe of their own colour leaking past the border, + because outlines were drawn with line primitives and pixel snapping while fills were drawn as + rectangles, and the two disagree by a pixel.
    • +
    +
    + +

    + There is a second habit worth forming here, and it costs about twenty lines: make the app able to + render one frame and quit, so a screen can be looked at without a display and a build can check + that the thing still draws. +

    + +
    +
    chisel/support/wants-screenshot.gsnew
    +
    import "ghost:os"
    +
    +// Whether the app was asked to render one frame, save it, and quit:
    +//
    +//   lumen . --shot
    +//
    +// Every bug in this project that survived to a real run lived in a file the
    +// test suite cannot execute, because it imports `lumen:`. Being able to run the
    +// whole app headlessly is worth the six lines.
    +function wantsScreenshot() {
    +  for (argument in os.args()) {
    +    if (argument == '--shot') {
    +      return true
    +    }
    +  }
    +
    +  return false
    +}
    +
    + +

    + Wire that into draw() — screenshot on the second frame, then lumen.quit() + — and add a variant that runs a command first, so screens reachable only by clicking can be + rendered too. lumen . --shot run:app.preferences opens the preferences dialog, draws + it once and exits. Those are exactly the screens least likely to have been looked at, and doing it + found four faults in that one dialog: a modal scrim so opaque it blanked the workspace instead of + putting it out of reach, a dialog sized for two thirds of the screen, a theme missing from its own + theme picker, and buttons that had disappeared. +

    +
    + +
    + +
    +

    Part 10Dock — carving the workspace

    Aseprite's workspace is not floating windows. Regions are carved off the edges of the available @@ -2288,7 +2644,7 @@

    Why this works, and a Ghost detail that makes it possible

    window.setResizable(true) window.setVsync(true) - theme = asepriteDark().useScale(1).loadFonts(null) + theme = asepriteMocha().useScale(1).loadFonts(null) app.ui = new Ui(theme, new Painter(theme)) @@ -2329,8 +2685,8 @@

    Why this works, and a Ghost detail that makes it possible


    -
    -

    Part 10Studio — the shell, and what we deliberately did not build

    +
    +

    Part 11Studio — the shell, and what we deliberately did not build

    Chisel is finished as a framework: rectangles, painting, widgets, input. What it cannot do is host @@ -2589,8 +2945,8 @@

    What replaced the service providers


    -
    -

    Part 11Commands and the keymap — a desktop app's routes file

    +
    +

    Part 12Commands and the keymap — a desktop app's routes file

    Here is the one place the Laravel analogy earns its keep rather than decorating. A menu item, a @@ -3006,7 +3362,7 @@

    Three more, in one file

    Build a Studio in load(), register one throwaway command, bind it, and forward keys to the shell:

    -
    app.studio = new Studio(asepriteDark().useScale(1).loadFonts(null))
    +
    app.studio = new Studio(asepriteMocha().useScale(1).loadFonts(null))
     app.ui = app.studio.ui
     
     app.studio.commands.add(new Command('hello', 'Say Hello')
    @@ -3026,8 +3382,8 @@ 

    Three more, in one file


    -
    -

    Part 12The editor chrome

    +
    +

    Part 13The editor chrome

    Four widgets fill the dock. They are all the same two ideas — a container that arranges children, @@ -3164,10 +3520,17 @@

    Colour bar

    painter.fill(cell, this.colors[index]) + // The selected swatch is marked by the colour of its own border, not by + // anything drawn beside it. A tick is unreadable at this size and would + // have to be light on dark swatches and dark on light ones; a mark in the + // gap between them just reads as a smudge. + edge = ui.theme.of('outline') + if (index == this.index) { - painter.bevel(cell, false) - painter.hline(cell.x, cell.y - 1, cell.w, ui.theme.of('accent')) + edge = ui.theme.of('accent') } + + painter.surface(cell, this.colors[index], edge, 1) } } @@ -3258,8 +3621,8 @@

    Status bar


    -
    -

    Part 13The document, the tools, and the canvas

    +
    +

    Part 14The document, the tools, and the canvas

    Everything so far is editor-agnostic. Now the pixel editor: a document, a tool, and the viewport @@ -3683,7 +4046,7 @@

    The viewport

    return null } - grid = ui.theme.of('bevel.dark') + grid = ui.theme.of('outline') width = this.document.width() * this.zoom height = this.document.height() * this.zoom @@ -3818,7 +4181,7 @@

    The editor that ties it together

    window.setResizable(true) window.setVsync(true) - app.studio = new Studio(asepriteDark().useScale(1).loadFonts(null)) + app.studio = new Studio(asepriteMocha().useScale(1).loadFonts(null)) editor = app.studio.register(new SpriteEditor(app.studio)) @@ -3849,8 +4212,8 @@

    The next optimisation, when you need it


    -
    -

    Part 14The map editor, mostly for free

    +
    +

    Part 15The map editor, mostly for free

    The map editor is the same shell with a different document, a different tool set and one changed @@ -3975,7 +4338,7 @@

    Part 14The map editor, mostly for free

    main.gsedit
    -
      app.studio = new Studio(asepriteDark().useScale(1).loadFonts(null))
    +
      app.studio = new Studio(asepriteMocha().useScale(1).loadFonts(null))
     
       sprites = app.studio.register(new SpriteEditor(app.studio))
       maps = app.studio.register(new MapEditor(app.studio))
    @@ -3986,8 +4349,8 @@ 

    Part 14The map editor, mostly for free


    -
    -

    Part 15Leaving room for the sound editor

    +
    +

    Part 16Leaving room for the sound editor

    The sound editor is the third tenant of this toolkit, and it is worth designing for now @@ -4085,8 +4448,8 @@

    The honest blocker: Lumen's audio API cannot support a sound editor yet


    -
    -

    Part 16Ship it

    +
    +

    Part 17Ship it

    Preferences belong in the player's own data directory, which Preferences already @@ -4232,12 +4595,42 @@

    Ghost

    Would fix it a compile-time warning when a class member shadows an import — the same fix the everything-is-exported item needs would not reach this, since it is member resolution order, not visibility.

    +
    +
    A local variable permanently destroys a same-named method on the objecthigh · shipped twice
    +

    + The same resolution order as the item above, cutting deeper. A method's locals are not scoped to + that method: assigning gap = 4 inside one method replaces the method + gap() on that object, for the object's lifetime, and every later call from + anywhere in the class raises is a number, which cannot be called. +

    +
    class Probe {
    +  gap() { return 7 }
    +  first() { gap = 99; return gap }
    +  second() { return this.gap() }
    +}
    +
    +p = new Probe()
    +
    +p.second()   // 7
    +p.first()
    +p.second()   // type error: `Probe.gap` is a number, which cannot be called
    +

    + The dangerous property is that it is time-dependent. The same call works before + the poisoning line has run and fails after, so whether a test catches it depends on the order + the methods happen to be exercised in. It shipped here twice: once in a scrollbar that had never + been painted, and again — after a linter had been written for it — in a colour bar, because the + first version of that check only looked for the call in the same method as the local. + It is any local sharing a name with any method of its class. +

    +

    Would fix it function-local scoping for assignments, which is the same underlying change as the "assignment does not walk outward" item — one design decision producing two opposite-looking bugs.

    +
    +
    No statics: no named constructors, no class constantsmid

    Rect.fromBounds(...) and Button.HEIGHT are both impossible, so factories become free functions in helper files and constants move onto a theme object. Workable — it is - why asepriteDark() is a function — but it is the single thing that most distorts + why asepriteMocha() is a function — but it is the single thing that most distorts library shape.

    @@ -4414,7 +4807,7 @@

    RefThe whole thing, on one screen

    ├── traits/tappable.gs trait Tappable ├── traits/emits-events.gs trait EmitsEvents ├── theme.gs painter.gs pointer.gs ui.gs widget.gs -├── themes/aseprite-dark.gs function asepriteDark() +├── themes/aseprite-mocha.gs function asepriteMocha() ├── layout/dock.gs └── widgets/panel.gs button.gs toolbar.gs swatches.gs menu.gs statusbar.gs ruler.gs @@ -4435,7 +4828,8 @@

    RefThe whole thing, on one screen

    [taken, rest] = rect.splitTop(n) // splitBottom, splitLeft, splitRight // painting (inside paint(ui)) -ui.painter.panel(rect, face) .well(rect) .bevel(rect, raised) .groove(x, y, w) +ui.painter.panel(rect, face) .raised(rect, face) .sunk(rect, face) .well(rect, face) +ui.painter.surface(rect, fill, edge, cut) .chamfered(rect, paint, cut) .groove(x, y, w) ui.painter.textIn('body', text, rect, 'center', 'middle', ink) ui.theme.of('button.face') ui.theme.metric('row') ui.theme.font('small') diff --git a/tools/check-tutorial.py b/tools/check-tutorial.py new file mode 100755 index 0000000..b974e2e --- /dev/null +++ b/tools/check-tutorial.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Check that the code in docs/tutorial.html still parses. + + tools/check-tutorial.py + +The tutorial tells a reader to type out whole files. If one of them stops +parsing, the reader finds out by typing it in and getting a syntax error with +no way to tell whether they mistyped it or the page is wrong - which is the +worst possible failure for a document whose entire premise is "type this". + +It has happened. The tutorial spent two rebuilds describing an interface the +code no longer had: bevelled surfaces, circular corners, rendering at window +resolution. Prose going stale is hard to catch automatically; code going stale +is not, and this catches the half that can be. + +Only blocks marked `new` are checked. Those are whole files. A block marked +`edit` is a fragment - a few methods added to a class - and cannot parse alone. +""" + +import html +import os +import re +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +WHOLE_FILE = re.compile( + r'
    ([^<]+\.gs)new
    \s*' + r'
    (.*?)
    ', + re.S, +) + + +def blocks(source): + for match in WHOLE_FILE.finditer(source): + yield match.group(1), html.unescape(match.group(2)) + + +def main(): + ghost = os.environ.get("GHOST", "ghost") + page = os.path.join(ROOT, "docs", "tutorial.html") + + if not os.path.exists(page): + print("docs/tutorial.html is missing") + return 1 + + source = open(page).read() + failures = 0 + checked = 0 + + with tempfile.TemporaryDirectory() as work: + for name, code in blocks(source): + checked += 1 + path = os.path.join(work, name.replace("/", "__")) + open(path, "w").write(code) + + # `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. + result = subprocess.run( + [ghost, path], capture_output=True, text=True, timeout=30 + ) + output = result.stdout + result.stderr + + if "syntax error" in output: + print(f"syntax error in the {name} block:") + + for line in output.splitlines(): + if line.strip(): + print(" " + line) + + failures += 1 + + print() + print(f"{failures} problem(s); {checked} whole-file code blocks checked") + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())