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. 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/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/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/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/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..61f5fdd 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,15 +38,16 @@ 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']) + .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 1f1b254..59cfa10 100644 Binary files a/resources/icons.png and b/resources/icons.png differ diff --git a/shot.gs b/shot.gs index b217fe8..e8eeb37 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,22 +23,23 @@ 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']) + .define(['eye', 'lock']) app.ui.cursors = new Cursors('resources/cursors.png', 8) .define('arrow', 1, 0) 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/studio/sprite/editor.gs b/studio/sprite/editor.gs index f6e5376..28967d3 100644 --- a/studio/sprite/editor.gs +++ b/studio/sprite/editor.gs @@ -167,9 +167,9 @@ 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) + 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 9f96f65..3cad534 100644 --- a/studio/studio.gs +++ b/studio/studio.gs @@ -68,10 +68,11 @@ 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']) + .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/tests/core.gs b/tests/core.gs index fdc7772..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" @@ -475,28 +476,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) @@ -525,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/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..825b530 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): @@ -150,31 +151,76 @@ 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') + + if not methods: + continue - for index, (at, method) in enumerate(starts): - end = starts[index + 1][0] if index + 1 < len(starts) else len(src) - body = src[at:end] + 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 - assigned = set(re.findall(r'^\s+(\w+)\s*=\s*[^=]', body, re.M)) - called = set(re.findall(r'this\.(\w+)\s*\(', body)) + 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 - 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 +# --- 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..9b00aba 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,226 @@ 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": [ + "................", "................", "..##........##..", + "..###......###..", "...###....###...", "....###..###....", + ".....######.....", "......####......", "......####......", + ".....######.....", "....###..###....", "...###....###...", + "..###......###..", "..##........##..", "................", + "................", + ], + "eye": [ + "................", "................", "................", + ".....######.....", "...##++++++##...", "..#++++##++++#..", + ".#+++#++++#+++#.", "#++++#++++#++++#", ".#+++#++++#+++#.", + "..#++++##++++#..", "...##++++++##...", ".....######.....", + "................", "................", "................", + "................", + ], + "lock": [ + "................", ".....######.....", "....##++++##....", + "...##+....+##...", "...#+......+#...", "...#+......+#...", + "..############..", "..#++++++++++#..", "..#+++####+++#..", + "..#+++#..#+++#..", "..#+++####+++#..", "..#++++##++++#..", + "..#++++##++++#..", "..############..", "................", + "................", + ], } # --- file icons: full colour, 15x16, as measured off Picotron ----------------- @@ -195,7 +385,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,))