Style¶
How what you draw looks: colours, strokes, gradients, shadows, tint and blending, and text. A
style setting applies to every render call after it, until the end of the frame or the with
block it was set in.
Defaults¶
Style() defaults are not blank: stroke BLACK, on, 1 unit; fill WHITE, off; BLACK
text. Every frame opens with a clear to gray 200 so those defaults are visible (see
Drawing). So a shape drawn with no style at all is a thin black
outline.
Colours¶
fill, stroke and text_color set three independent colours: fill_enabled(False) does not
hide text, and text is hidden by a zero-alpha text_color instead.
fill_enabled and stroke_enabled switch a part off without losing its colour.
fill(...), stroke(...) and shadow(...) switch it back on, and with no arguments keep every
part, so fill() alone paints the default white.
Three ways to style¶
All three are additive:
| Form | Effect |
|---|---|
canvas.fill(Color.RED) (any setter) |
Rest of the frame |
with canvas.style(fill=Color.RED): |
Only the named fields, for the block (each goes through its setter) |
with canvas.style(heading): |
A whole Style value, for the block — unset fields are Style() defaults, not the previous style |
from create import *
@fieldwise_init
struct Styles(Program):
var label: Style
@staticmethod
def create(mut context: Context, mut canvas: Canvas) raises -> Styles:
return Styles(Style(text_color=Color.BLUE, font_size=32))
def update(mut self, mut context: Context, mut canvas: Canvas) raises:
canvas.background(Color.WHITE)
canvas.fill(Color.ORANGE) # for the rest of the frame
canvas.circle((-150, 0), 50)
with canvas.style(stroke=Color.RED, stroke_thickness=6):
canvas.circle((0, 0), 50) # orange, thick red stroke
canvas.circle((150, 0), 50) # orange, thin black stroke again
with canvas.style(self.label):
canvas.text("Three ways", (0, -120))
def main() raises:
run[Styles]("Styles")
Style(...) takes the setters' names as keywords and, like them, naming any part of the fill,
stroke or shadow switches it on unless its *_enabled keyword says otherwise. A reusable style is
a field built in create, like label above.
A Float64 keyword (blur, spread, thickness, font_size, opacity) takes 12, 1.5,
None or an Optional[Float64] alike. It is the internal _OptionalFloat64, since an integer
literal won't convert through Optional[Float64] in one step. Every optional keyword left unset
is None.
Corner radius is style only for shapes the canvas builds from numbers: canvas.corner_radius(r)
rounds the next canvas.rectangle(...). A shape value keeps its own (see
corner_radius), so with canvas.style(corner_radius=8): canvas.draw(rect) does
not round rect.
Strokes¶
Strokes are style like the rest: canvas.stroke(color, thickness), stroke_enabled, two
settings that place the paint and one that breaks it into dashes.
stroke_alignment(StrokeAlign.INSIDE/CENTER/OUTSIDE) puts a region's stroke inside its edge
(the default), centred on it, or around it. The fill meets the stroke's inner edge, so OUTSIDE
leaves the whole shape to the fill.
- Regions only (rectangle, circle, triangle, sector, polygon): lines and curves have no inside and are always centred. A self-crossing polygon's seams (edges through its inside) stay centred under every alignment.
INSIDEkeeps drawn pixels within whatcontains,overlapsandclipsee.- Shadows and a gradient fill follow the shape, not the stroke: the gradient spans the true shape whatever the alignment.
stroke_cap(StrokeCap.BUTT/ROUND/SQUARE) ends an open stroke — a Line, Arc, Bezier or
open Spline — flush (the default), with a half disc, or half the thickness further, square.
- A closed curve and a shape's stroke have no ends, unless dashed: then every dash has two.
- A zero-length line draws nothing with
BUTT, a dot withROUNDand an axis-aligned square withSQUARE. - Caps composite with the stroke once, translucent or not, and are part of its shadow, so
segments drawn end to end with
ROUNDmeet without notches.
stroke_dash(...) breaks every stroke into dashes, regions' included. A preset is a finished
look and scales with the thickness; a constructor gives exact lengths in world units, scaled like
the thickness by autoscale, the camera and the transform:
| Value | Stroke |
|---|---|
StrokeDash.SOLID |
Unbroken (the default) |
StrokeDash.DASHED |
Dashes 3 thicknesses long, 2 apart |
StrokeDash.DOTTED |
Round dots as wide as the stroke, 2 thicknesses apart |
StrokeDash(12, 6) |
Dashes 12 long, 6 apart |
StrokeDash(12, 4, 2, 4) |
Up to four on/off pairs, repeated (dash-dot here) |
# A fragment of an `update`: marching ants round a selection.
var ants = StrokeDash(6, 6).with_offset(context.time.elapsed * 20)
with canvas.style(stroke=Color.BLACK, fill_enabled=False, stroke_dash=ants):
canvas.draw(self.selection)
- Each dash is a stroke of its own:
stroke_capfinishes both its ends, and one round a corner is mitred. A dot is a dash of no length, soStrokeDash(0, 8)draws dots underROUND, squares underSQUAREand nothing underBUTT;DOTTEDis round whatever the cap. Caps that reach into each other overlap, which a translucent stroke shows. - Where it starts: a line at its start, a curve at its first point, a polygon or triangle at
its first vertex, in their own order; a rectangle at its top-left corner and a circle or ellipse
at its right-most point, counter-clockwise; a sector at its tip, out along its start radius.
offsetstarts that far into the pattern, so raising it marches the dashes back along the stroke. A closed outline doesn't stretch the pattern to fit, so a dash can meet a shorter one where it comes round. - Lengths run along the middle of the stroke, after
stroke_alignmenthas placed it. The fill still meets the stroke's inner edge, so a gap shows what is behind the shape; withOUTSIDEthe fill lies under the gaps. - Shadows: a stroke alone casts its dashes; a filled shape casts its whole silhouette, gaps included.
- A pattern repeating in under a device pixel can't be seen and is drawn solid.
Gradients¶
A Gradient fills regions and backgrounds: canvas.fill(gradient) and
canvas.background(gradient) are overloads beside the colour ones. Build one in create and
keep it as a field (or in a Style): construction samples its colour ramp, and the GPU uploads
one ramp per distinct set of stops.
# A fragment: `sky` is a field, built in `create`.
var sky = Gradient.linear(Color(20, 30, 80), Color(250, 150, 90)) # top to bottom
var glow = Gradient.radial(Color.WHITE, Color.TRANSPARENT)
# In `update`:
canvas.background(self.sky)
with canvas.style(fill_gradient=self.glow, stroke_enabled=False):
canvas.circle((0, 0), 80)
A gradient is its stops (0..1 positions, each a Color) and how it runs:
- Placed by the shape's local bounding box, before the transform, so it moves and turns with
the shape and one
Stylesuits every entity. A sector's box is its whole circle. linearruns alongdirection(aVector2D, defaultDOWN, any length); the box's extreme corners land on 0 and 1, as in CSS.Gradient.linear([(0.0, Color.BLUE), (0.7, Color.PURPLE), (1.0, Color.ORANGE)])places the stops; given colours alone, they are spread evenly.radialruns fromcenterin the box's unit space (-1..1, y up) out to its edges: an ellipse on a long box.- A zero
directionor a zero-size box paints the first stop. - It blends premultiplied, so a fade to
TRANSPARENTstays clean, and is dithered on both backends.
How it combines with the rest of the style:
- In
Styleandcanvas.style, the keyword isfill_gradient=, notfill=: one keyword can't take both types, and anOptional[Paint]wrapper would need two implicit conversions from a bareColor, which Mojo won't chain. Givenfill=andfill_gradient=together, the gradient wins. - While a gradient is set it fills, and
fill_coloris kept:fill(color)clears the gradient, whilefill()andfill_enabled(...)keep it. - It fills only. Strokes, lines, curves and text ignore it, and a shadow stays the silhouette in
shadow_color(a fill fading to transparent still casts a full shadow). background(gradient)spans the screen (left()…top()), ignoring the camera, and replaces the autoclear only if every stop is opaque.
Shadows¶
Shadows are part of the style and off by default. canvas.shadow(color=, offset=, blur=,
spread=, inset=) switches one on; parts left unset keep their values, so shadow() alone
switches the current one back on. shadow_enabled(False) switches it off, and canvas.style(...)
takes the same parts as shadow* keywords (shadow= for the colour):
# A fragment of an `update`.
with canvas.style(shadow=Color(0, 0, 0, 120), shadow_blur=12):
canvas.rectangle((0, 0), 200, 120)
- One silhouette, like CSS
drop-shadow: fill and stroke cast together (a stroke alone casts a ring); lines and arcs cast their stroke, text its glyphs, images their alpha. A translucent fill shows its own shadow through it. The clear, the background and the letterbox never cast. bluris the CSS radius (σ = blur / 2).blurandspreadare world units, scaled like coordinates. Blur 0 is hard.shadow_insetpaints inside the shape instead, after it, clipped to the interior within the stroke. Rectangles, circles and triangles only: sectors, polygons, lines, arcs, text and images cast nothing while it is set.- The offset is screen-fixed by default (
(4, -4), down-right): rotation doesn't turn it, but camera zoom and autoscale scale it.shadow_follows_transform(True)turns it with the shape. - Shadows use the render call's own tint, opacity and blend mode.
Tint, opacity and blend modes¶
These reach every render call, images included (background() never), and every shape composites
once: fill and stroke never overlap, so a translucent shape is evenly translucent.
canvas.tint(color)multiplies every colour of the call — fill, gradient stops, stroke, text, shadow, each image texel — channel by channel. White (the default) is no tint, and its alpha stacks withopacity. It recolours a whole look at once, wherefill,strokeandtext_colorset one part each; scope it withwith canvas.style(tint=...). Multiplying only darkens, so it can't flash a sprite white.canvas.opacity(a), from 0 to 1, fades the whole call.canvas.blend_mode(mode)sets how the call combines with what is already drawn:NORMAL(the default),ADD,MULTIPLY,SUBTRACTorSCREEN.
Text and fonts¶
canvas.text(string, position) draws text in the current text style. Fonts are part of the
style: canvas.font(f), Style(font=f) or with canvas.style(font=f):, beside font_size,
font_weight and font_italic:
# A fragment: `title_font` is a field, loaded in `create`.
var title_font = Font.load("fonts/Title.ttf")
# In `update`:
with canvas.style(font=self.title_font, font_size=48, font_weight=FontWeight.BOLD):
canvas.text("Game over", (0, 0))
- A
Fontis kept as a field, like anImage: a shared asset, cheap to copy, closed when its last copy goes. - With no font set, text is the packaged Noto Sans, and a character the face lacks falls back to Noto Sans Symbols.
- Each
textcall keeps its face, so several share a frame. Like every style setter,canvas.fontresets next frame. Switching faces costs nothing: cached glyphs are keyed by face.
Italic is canvas.font_italic(True) (or the font_italic= keyword), and always slants, from
the best source the font has: the italic_path file of Font.load(path, italic_path=...) (the
packaged Noto Sans ships its italic this way), else the file's own ital or slnt axis, else its
upright glyphs sheared 12°. Fallback glyphs follow, so Noto Sans Symbols comes out sheared. Weight
reaches a variable italic like an upright. A shear's overhang is not measured: text_width is the
advance, as in CSS.
Lines. A newline ("\n", or "\r\n") in the string starts a new line, the font's line
height below. Each line is aligned on its own by text_alignment, whose top and bottom are those
of the whole block. canvas.text_width(string) is the widest line's width, and
canvas.text_height(string) the block's height, from the first line's ascent to the last line's
descent:
# A fragment of `update`: a two-line caption hanging from the top-left corner,
# and a line under it.
var caption = "Level 1\nThe caves"
var corner = Point2D(canvas.left() + 20, canvas.top() - 20)
canvas.text_alignment(Align.TOP_LEFT)
canvas.text(caption, corner)
var under = corner.y - canvas.text_height(caption) - 4
canvas.line((corner.x, under), (corner.x + canvas.text_width(caption), under))
Wrapping. canvas.text(string, position, width) also wraps each line to at most width
units, breaking at the last space that fits and dropping the spaces there; a word wider than
width alone breaks between its letters. Newlines still break, and every line is still aligned
on its own, so Align.TOP_LEFT sets ragged-right text hanging from position.
canvas.text_width(string, width=w) and canvas.text_height(string, width=w) measure the wrapped
block:
# A fragment of `update`: a paragraph in a 300-unit column, a panel behind it.
var story = "The caves run deeper than the old maps show, and nobody who went down came back."
canvas.text_alignment(Align.TOP_LEFT)
var corner = Point2D(canvas.left() + 20, canvas.top() - 20)
var height = canvas.text_height(story, width=300)
canvas.fill(Color(0, 0, 0, 160))
canvas.rectangle((corner.x + 150, corner.y - height / 2), 300, height)
canvas.text_color(Color.WHITE)
canvas.text(story, corner, 300)