Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
19 changes: 13 additions & 6 deletions chisel/painter.gs
Original file line number Diff line number Diff line change
Expand Up @@ -201,22 +201,29 @@ 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)
}

if (corners == null) {
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
Expand Down
51 changes: 51 additions & 0 deletions chisel/support/catppuccin.gs
Original file line number Diff line number Diff line change
@@ -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'
}
}
54 changes: 54 additions & 0 deletions chisel/support/hsv.gs
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 16 additions & 9 deletions chisel/support/logical-size.gs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions chisel/themes/aseprite-latte.gs
Original file line number Diff line number Diff line change
@@ -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
}
93 changes: 93 additions & 0 deletions chisel/themes/aseprite-mocha.gs
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading