feat(ui): nine-slice corners clamped to half the box in each direction, cut on whole pixels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 21:59:26 +03:00
parent 12f4ba8431
commit cf8b9e425e
9 changed files with 53 additions and 7 deletions

View file

@ -532,7 +532,9 @@ applies to both:
- Boxes: `border-radius`, `box-shadow` (sharp, offset), `background-image: url(...)`, and
`border-image: url(...) slice / width` as a nine-slice. With no background colour a nine-slice
is drawn as painted, as CSS draws one; with one it is tinted by it, so one rounded texture serves
every colour, and `transparent` (or any colour with no alpha) leaves nothing to see.
every colour, and `transparent` (or any colour with no alpha) leaves nothing to see. Its
corners are at most half the box across and half of it down, each way on its own, and cut on
whole pixels (`ui_nine_cuts(x, y, w, h, width)` for a renderer of its own).
- Text: `text-shadow: x y blur colour` draws the text again under itself, offset (the blur is
drawn sharp), and is inherited. `text-fit: shrink 12px` draws a line too long for its box
smaller, down to that size, then cuts what still does not fit with an ellipsis (for a chip

View file

@ -94,6 +94,8 @@ What a game's screens found missing, now in `ludic.ui`:
rule), not the one it had so far;
- `min()`, `max()` and `clamp()`, in `calc()` or on their own; `top` / `right` / `bottom` / `left`
as a percentage or a `calc()` of one, of the containing block;
- a nine-slice's corners are clamped to half the box in each direction on its own and cut on whole
pixels (`ui_nine_cuts`), so a small key cap has no seam;
- a transition lands exactly on its end value (it had stopped a rounding error short of it, at every
frame rate).

View file

@ -0,0 +1,19 @@
# ui_nine.ludic — a nine-slice's cuts: its corners are at most half the box across and half of it
# down, each direction on its own, so a key cap shorter than two corners keeps them across; the cuts
# land on whole pixels so the pieces meet.
import "ludic.ui"
program UiNine {
numbers float
function cuts(x: float, y: float, w: float, h: float, dst: float) -> string {
let c = ui_nine_cuts(x, y, w, h, dst)
var s = ""
for i in 0 .. 8 {
if i == 4 { s = s + "/" }
s = s + ` {int(c[i])}`
}
return s
}
entry {
print(`cap{cuts(0.0, 0.0, 30.0, 16.0, 12.0)} | wide{cuts(0.0, 0.0, 100.0, 40.0, 12.0)} | odd{cuts(10.4, 3.6, 20.3, 9.0, 14.0)}`)
}
}

View file

@ -8,7 +8,7 @@ export property UiBackend {
text: fn(float, float, string, float, int, float) -> void = null # x y text size rgb alpha
measure: fn(string, float) -> float = null
image: fn(string, float, float, float, float, int, float) -> void = null # src x y w h tint alpha
nine: fn(string, float, float, float, float, float, float, int, float) -> void = null # src slice x y w h width tint alpha
nine: fn(string, float, float, float, float, float, float, int, float) -> void = null # src slice x y w h width tint alpha (ui_nine_cuts)
clip: fn(float, float, float, float) -> void = null
unclip: fn() -> void = null
scale: fn() -> float = null # screen pixels per design pixel

View file

@ -42,7 +42,7 @@ function fr_background(n: UiNode) -> void {
}
function fr_fill(n: UiNode, x: float, y: float, c: int, a: float) -> void {
if n.bi_src != "" and ui_be.nine != null {
ui_be.nine(n.bi_src, n.bi_slice, x, y, n.cw, n.ch, Math.min(n.bi_width, Math.min(n.cw, n.ch) / 2.0), c, a)
ui_be.nine(n.bi_src, n.bi_slice, x, y, n.cw, n.ch, n.bi_width, c, a)
return
}
fr_box(x, y, n.cw, n.ch, n.radius, c, a)

View file

@ -0,0 +1,19 @@
# draw_nine.ludic - where a nine-slice is cut: its corners as wide as border-image says, but never
# more than half the box across or half of it down (each direction on its own, so a small key cap
# keeps its round ends and has no seam), on whole pixels so the nine pieces meet exactly
# x0 x1 x2 x3 then y0 y1 y2 y3: the columns' and the rows' edges
export function ui_nine_cuts(x: float, y: float, w: float, h: float, dst: float) -> []float {
let dx = Math.max(Math.min(dst, w / 2.0), 0.0)
let dy = Math.max(Math.min(dst, h / 2.0), 0.0)
let out = new []float
push(out, nn_px(x))
push(out, nn_px(x + dx))
push(out, nn_px(x + w - dx))
push(out, nn_px(x + w))
push(out, nn_px(y))
push(out, nn_px(y + dy))
push(out, nn_px(y + h - dy))
push(out, nn_px(y + h))
return out
}
function nn_px(v: float) -> float { return Math.floor(v + 0.5) }

View file

@ -83,4 +83,5 @@ import "screen_class.ludic"
import "draw.ludic"
import "draw_content.ludic"
import "draw_order.ludic"
import "draw_nine.ludic"
import "dump.ludic"

View file

@ -1,14 +1,16 @@
# render3d_nine.ludic - border-image for the render3d backend: a nine-slice of a texture
module ludic_ui
# border-image: the texture's `slice`-pixel corners kept, the rest stretched, drawn `dst` wide; the
# slice is a share of the texture's own width across and its own height down, so it need not be square
# border-image: the texture's `slice`-pixel corners kept, the rest stretched, drawn `dst` wide (at
# most half the box each way, ui_nine_cuts); the slice is a share of the texture's own width across
# and its own height down, so it need not be square
function r3u_nine(src: string, slice: float, x: float, y: float, w: float, h: float, dst: float, c: int, a: float) -> void {
let t = r3u_tex(src)
if t == 0 { return }
let su = slice / float(Math.max(tex_width(t), 1))
let sv = slice / float(Math.max(tex_height(t), 1))
let xs = r3u_four(x, x + dst, x + w - dst, x + w)
let ys = r3u_four(y, y + dst, y + h - dst, y + h)
let cut = ui_nine_cuts(x, y, w, h, dst)
let xs = r3u_four(cut[0], cut[1], cut[2], cut[3])
let ys = r3u_four(cut[4], cut[5], cut[6], cut[7])
let us = r3u_four(0.0, su, 1.0 - su, 1.0)
let vs = r3u_four(0.0, sv, 1.0 - sv, 1.0)
for j in 0 .. 3 {

View file

@ -768,6 +768,7 @@ function cmd_dev_test() -> int {
controller_case("library/ui_popover_align", "", "centred, flipped in the panel 90,40 | end below 110,70 | above b in the list 0 inside 1", "ui_popover_align.ludic (L11: anchored popovers aligned along their side, flipped and kept within a named container or their scroll box)")
controller_case("library/ui_text", "", "Photograph the Be...@100 y0 | Short@160 y20 | Medium words@133 y40 | lines 5 160 one two@160 y66 three@160 y98 | tall 30 | em 20 24 | tie #00ff00", "ui_text.ludic (L11: text-fit shrinks then cuts, line-height, em against the final font size, a sheet over its imports on a tie)")
controller_case("library/ui_calc", "", "min 200 max 82 clamp 50 21 | p 150,60 q 255,80 r 30", "ui_calc.ludic (L11: min(), max() and clamp(), and insets as percentages and calc() of the containing block)")
controller_case("library/ui_nine", "", "cap 0 12 18 30/ 0 8 8 16 | wide 0 12 88 100/ 0 12 28 40 | odd 10 21 21 31/ 4 8 8 13", "ui_nine.ludic (L11: a nine-slice's corners clamped to half the box each way, on whole pixels)")
controller_case("library/ui_inputs", "", "count 12 12 32 0.5 0 | 20 32 0.5 0 | 19 32 0.5 0 | How many to carry | Space bar listening 1 19 9 0.5 0 | 19 9 0.5 0 | 19 0 0.5 0 | 60% 0.50 s | 19 0 0.6 9 | 7!", "ui_inputs.ludic (L11: number fields, key capture with Esc and Backspace, notes, range formats, an event named set, a string prop given a number)")
controller_case("library/ui_boxes", "", "list 120 of 200 | text at 32 152 wide, 3 lines, row 60 | zero 0x0 | half 140x36 pad 4 2 | order abc at 16 | fill 98x8 of 200x12", "ui_boxes.ludic (L11: flex-shrink to the leftover room, text wrapping beside its siblings, zero sizes, calc(), order, the range track as a row)")
controller_case("library/ui_look", "", "root div.in.i.m pad 5 margin 9 color #ff0000 text Hi 2,53 #102030 | text Hi 0,50 #eeeeee | opacity 0.25 | nine 40 #ffffff nine 40 #ff0000 of 2 image photo.png #ffffff | image icon:tent #00ff00 | text first line 29,205 #eeeeee | text second line 29,223 #eeeeee by id at 84,50 | flipped at 246,268 100x32", "ui_look.ludic (L11: nested components styled by specificity, text-shadow, ui_opacity, border-image tints, pictures, tooltip lines, anchored popovers)")