Skip to content

Latest commit

 

History

History
560 lines (482 loc) · 26.9 KB

File metadata and controls

560 lines (482 loc) · 26.9 KB

Stroke

The Stroke panel edits the stroke attributes of the selected path(s). This document is the requirements description from which workspace/panels/stroke.yaml is generated.

Overview

The Stroke panel is one tab in a tabbed panel group (alongside Properties); the tabbed-group container is specified elsewhere. This document covers only the Stroke tab.

The panel edits stroke attributes on the selected elements: weight, cap style, join style with miter limit, stroke alignment, dash pattern, arrowheads with per-end shape and scale, arrow alignment, and a variable-width profile. All ten controls read from and write to the flat state.stroke_* surface, which is the single source of truth; the panel's own panel.* fields mirror state.* for binding convenience and are re-initialised from state.* each time the panel opens.

When no path is selected, the panel still binds the defaults — edits become the new-path defaults for future draws. When a path (or paths) is selected, edits apply immediately to the selection. Selection- independent controls (like the profile dropdown) always behave the same.

Several fields are conditionally disabled:

  • The miter-limit input is disabled when the join style is round or bevel.
  • All six dash / gap inputs are disabled while the Dashed Line checkbox is off. Their values persist and reappear when re-enabled.
  • When state.stroke_brush != null, the brush takes over most of the native stroke pipeline. The cap, join (and miter limit), dash (and the six dash/gap inputs), arrowheads (start/end + scale + link + arrow alignment), and profile (+ flip + reset) controls all disable. WEIGHT_INPUT, the active stroke colour, and ALIGN_*_BUTTON (for Calligraphic and Bristle brush types only) remain live since the brush renderer consumes them. Disabled values persist and reappear when the brush is removed via the Brushes panel's REMOVE_BRUSH_STROKE_BUTTON. See transcripts/BRUSHES.md § Stroke styling interaction for the per-brush-type breakdown.

Controls

  • WEIGHT_INPUTnumber_input. Stroke weight in points; non-negative, decimals allowed. Committed on Enter or blur. Paired with a literal "pt" unit suffix.

  • CAP_BUTT, CAP_ROUND, CAP_SQUARE — three mutually exclusive icon_buttons for line cap style. Exactly one is checked. Icons are cap_butt, cap_round, cap_square. Default CAP_BUTT.

  • JOIN_MITER, JOIN_ROUND, JOIN_BEVEL — three mutually exclusive icon_buttons for line join style. Exactly one is checked. Icons are join_miter, join_round, join_bevel. Default JOIN_MITER.

  • MITER_LIMIT_INPUTnumber_input. Miter length / stroke width ratio. When the ratio of a miter join exceeds this value, the join auto-converts to bevel. Range ≥ 1. Disabled when panel.join is not miter.

  • ALIGN_CENTER, ALIGN_INSIDE, ALIGN_OUTSIDE — three mutually exclusive icon_buttons for stroke alignment relative to the path. ALIGN_INSIDE and ALIGN_OUTSIDE behave identically to ALIGN_CENTER on open paths. Default ALIGN_CENTER.

  • DASHED_CHECKBOXcheckbox enabling the dash pattern.

  • EVEN_DASH_PRESETicon_button that turns dashing on and sets pair 1 to dash=12 gap=12, pairs 2/3 null.

  • DASH_DOT_PRESETicon_button that turns dashing on and sets pair 1 to dash=12 gap=6, pair 2 to dash=0 gap=6 (a dot with round caps), pair 3 null.

  • DASH_1, GAP_1, DASH_2, GAP_2, DASH_3, GAP_3 — six number_inputs arranged as three dash/gap pairs under small literal labels ("dash" / "gap"). Pair 1 defaults to 12 / 12; pairs 2 and 3 default to null (blank, meaning unused). All six are disabled while DASHED_CHECKBOX is unchecked; values persist.

  • START_ARROWHEAD, END_ARROWHEAD — two selects over the same list of 15 arrowhead shapes (see Arrowhead shapes below).

  • SWAP_ARROWHEADS_BUTTONicon_button that exchanges the start and end arrowhead selections, swapping both the shape and the scale value.

  • START_SCALE, END_SCALE — two combo_boxes for arrowhead scale as a percentage of stroke weight. Presets: 50, 75, 100, 150, 200, 300, 400. Free numeric entry is allowed, minimum 1 %. Default 100 %.

  • LINK_SCALES_TOGGLEicon_button (chain icon). When active, changing either START_SCALE or END_SCALE updates both to the same value. Default off.

  • ARROW_TIP_AT_END, ARROW_CENTER_AT_END — two mutually exclusive icon_buttons for arrow alignment mode (see panel.arrow_align). Default ARROW_TIP_AT_END.

  • PROFILE_DROPDOWNselect over six variable-width profile options (see Stroke profile below). Default uniform.

  • FLIP_PROFILE_BUTTONicon_button that toggles panel.profile_flipped. Only visually meaningful for asymmetric profiles (taper_start, taper_end).

  • RESET_PROFILE_BUTTONicon_button that restores profile = uniform and profile_flipped = false in one click.

Layout

Strings in quotes are literal labels. Bare identifiers are widget IDs.

panel:
- .row:                                          # weight
  - "Weight"
  - WEIGHT_INPUT
  - "pt"
- .row:                                          # cap style
  - "Cap"
  - CAP_BUTT
  - CAP_ROUND
  - CAP_SQUARE
- .row:                                          # join style + miter limit
  - "Corner"
  - JOIN_MITER
  - JOIN_ROUND
  - JOIN_BEVEL
  - "Limit"
  - MITER_LIMIT_INPUT
- .row:                                          # alignment
  - "Align Stroke"
  - ALIGN_CENTER
  - ALIGN_INSIDE
  - ALIGN_OUTSIDE
- .row:                                          # dashed line + presets
  - DASHED_CHECKBOX
  - EVEN_DASH_PRESET
  - DASH_DOT_PRESET
- .row:                                          # dash/gap pattern (3 pairs)
  - DASH_1 (under "dash")
  - GAP_1  (under "gap")
  - DASH_2 (under "dash")
  - GAP_2  (under "gap")
  - DASH_3 (under "dash")
  - GAP_3  (under "gap")
- .row:                                          # arrowheads (start / end / swap)
  - "Arrowheads"
  - START_ARROWHEAD
  - END_ARROWHEAD
  - SWAP_ARROWHEADS_BUTTON
- .row:                                          # scale (start / end / link)
  - "Scale"
  - START_SCALE
  - END_SCALE
  - LINK_SCALES_TOGGLE
- .row:                                          # arrow align
  - "Align"
  - ARROW_TIP_AT_END
  - ARROW_CENTER_AT_END
- .row:                                          # profile (dropdown + flip + reset)
  - "Profile"
  - PROFILE_DROPDOWN
  - FLIP_PROFILE_BUTTON
  - RESET_PROFILE_BUTTON

Panel menu

  • Butt Cap (checkmark if active) — sets panel.cap = butt.
  • Round Cap (checkmark if active) — sets panel.cap = round.
  • Square Cap (checkmark if active) — sets panel.cap = square. The three cap items form a radio group mirroring CAP_BUTT / CAP_ROUND / CAP_SQUARE.

  • Miter Join (checkmark if active) — sets panel.join = miter.
  • Round Join (checkmark if active) — sets panel.join = round.
  • Bevel Join (checkmark if active) — sets panel.join = bevel. The three join items form a radio group mirroring JOIN_MITER / JOIN_ROUND / JOIN_BEVEL.

  • Close Stroke — dispatches close_panel with params: { panel: stroke }, hiding the Stroke tab.

Dashed line and the dash pattern

The dash pattern is six numeric values arranged as three consecutive dash/gap pairs. The effective SVG stroke-dasharray is built left to right from the non-null pairs only: if pairs 2 and 3 are null, only pair 1 contributes. A zero-length dash produces a dot that is visible when the cap style is round or square (and invisible when the cap is butt, which is the source of the dot-dash preset's reliance on round caps).

DASHED_CHECKBOX acts as the master switch:

  • When unchecked, stroke-dasharray is omitted from the element and all six dash/gap inputs are disabled. The values are preserved in panel.dash_1…gap_3 so the previous pattern reappears on re-check.
  • When checked, the six inputs are enabled and the dash pattern is applied.

Pair 1 always has numeric defaults (12 / 12); pairs 2 and 3 default to null. A blank input in pair 2 or 3 means the pair does not contribute to the dash array. Pair 1 cannot be blank — clearing DASH_1 or GAP_1 falls back to its default.

The two preset buttons (EVEN_DASH_PRESET, DASH_DOT_PRESET) enable dashing and overwrite the dash/gap values in a single click:

Preset pair 1 pair 2 pair 3
Even dash 12 / 12 null null
Dash-dot 12 / 6 0 / 6 null

Arrowhead shapes

The 15 shapes (identical option list for both START_ARROWHEAD and END_ARROWHEAD):

Shape Appearance
none no arrowhead (default)
simple_arrow filled triangle
open_arrow unfilled triangle
closed_arrow filled triangle with bar at base
stealth_arrow sharp swept-back chevron, filled
barbed_arrow curved swept-back, filled
half_arrow_upper upper half of filled triangle
half_arrow_lower lower half of filled triangle
circle filled disk
open_circle outline circle
square filled square
open_square outline square
diamond filled rhombus
open_diamond outline rhombus
slash perpendicular line across the path

The renderer flips shapes so they point outward from each end — the same shape value, selected for start vs end, produces a mirrored rendering as appropriate.

SWAP_ARROWHEADS_BUTTON exchanges both the shape selections and the scale values between the two ends; it does not toggle LINK_SCALES_TOGGLE.

Arrow alignment (panel.arrow_align) governs where the arrowhead sits relative to the path endpoint:

  • tip_at_end (default) — arrowhead tip is at the endpoint; body extends inward along the path.
  • center_at_end — arrowhead center is at the endpoint; tip extends beyond.

Stroke profile

PROFILE_DROPDOWN selects a variable-width profile applied along the path's length:

Profile Shape
uniform constant width (default)
taper_both tapers at both ends
taper_start tapers at the start end
taper_end tapers at the end end
bulge wider in the middle
pinch narrower in the middle

FLIP_PROFILE_BUTTON toggles panel.profile_flipped, mirroring the profile along the path. Only asymmetric profiles (taper_start, taper_end) render differently when flipped.

RESET_PROFILE_BUTTON sets profile = uniform and profile_flipped = false in a single click.

Panel state

All panel state mirrors the state.stroke_* surface and is re-initialised from it on panel open (see init: in the yaml):

Panel key Source state key
panel.weight state.stroke_width
panel.cap state.stroke_cap
panel.join state.stroke_join
panel.miter_limit state.stroke_miter_limit
panel.align_stroke state.stroke_align
panel.dashed state.stroke_dashed
panel.dash_1…gap_3 state.stroke_dash_1…gap_3
panel.start_arrowhead state.stroke_start_arrowhead
panel.end_arrowhead state.stroke_end_arrowhead
panel.start_arrowhead_scale state.stroke_start_arrowhead_scale
panel.end_arrowhead_scale state.stroke_end_arrowhead_scale
panel.link_arrowhead_scale state.stroke_link_arrowhead_scale
panel.arrow_align state.stroke_arrow_align
panel.profile state.stroke_profile
panel.profile_flipped state.stroke_profile_flipped

Every widget commit fires two effects: a set_panel_state for the mirror key and a set for the corresponding state.stroke_* key. This dual-write keeps the panel's immediate visual state and the document's authoritative state in sync without round-tripping through a re-init.

SVG attribute mapping

Attributes are written onto the selected path element:

Control SVG / CSS
Weight stroke-width
Cap (butt / round / square) stroke-linecap
Join (miter / round / bevel) stroke-linejoin
Miter limit stroke-miterlimit (omit when join ≠ miter)
Dashed + pattern stroke-dasharray = the non-null dash/gap pairs, flattened left-to-right; omit when dashed=false
Align stroke paint-order / custom — SVG has no native inside/outside stroke, so inside/outside are approximated via path offset; center = native
Start / End arrowhead custom marker references (marker-start, marker-end) resolving to per-shape markers
Arrowhead scale marker markerWidth / markerHeight scaled against stroke weight
Arrow align determines whether the marker reference uses its tip or center alignment variant
Profile custom attribute (jas:stroke-profile); applied at render time to vary stroke width along the path

Identity-value rule. When an attribute equals its default (weight = 1, miter limit = 10, none arrowhead, uniform profile, etc.), the attribute is omitted from the output rather than written, so defaults appear as absence.

Keyboard shortcuts

Shortcuts for Stroke panel actions are defined in workspace/shortcuts.yaml rather than here.

Panel-to-selection wiring status

The panel binds directly to the flat state.stroke_* surface via the dual-write pattern above: every commit updates both the panel mirror and the state.* key, and on each subsequent panel open the mirrors are re-initialised from state.*. This means Stroke panel writes propagate through each app's apply_stroke_panel_to_selection pipeline (same shape as Character's apply_character_panel_to_selection).

The field-scoped apply law

A Stroke-panel edit names the field the user just committed, and writes only what that field owns. Every other stroke attribute is preserved from the element being edited, per element. An edit to a field that owns no element attribute writes nothing at all — not even an undo step.

This is the law because the panel is not a picture of the selection. Most of its controls display panel state that never syncs from the selected element, so an apply that rebuilt the whole stroke would impose values the user never chose and, in most cases, cannot see. The weight field is the sharp case: it shows the selection's real width, but the panel's other fields sit at their defaults, so rebuilding on any edit reset a selected 5pt dashed arrowheaded line to a plain 1pt line. Picking an arrowhead changed the weight. (JYH, 2026-07-24.)

The display side has its own rule, and it is the mirror image of the apply rule: a control that shows a RENDERED geometry of the selection must show the SELECTION's value, not a panel default. Weight and cap / join do this, and the arrowhead group — start / end shape, start / end scale, and arrow alignment — joins them. It has to: the head size is 4 × weight × scale% on the canvas, so a Scale field frozen at its 100 % default while the selected element carries another value shows a number the head never had. The head then renders at (say) half, and committing the field's own displayed 100 % — which the field-scoped apply writes straight onto the element — jumps the head to full, a change the panel never showed. Draw a line whose new-element default scale was 50 %, move the default back to 100 %, reselect: the old display read 100 while the head was 50 (JYH, 2026-07-25, ARROWSCALE). The fix is display-only and does not touch the apply: the panel now mirrors the selection's arrowhead group, and because the field shows the element's true scale, committing it is a no-op instead of a silent jump. Syncing the display is safe precisely because the apply stays field-scoped — a truthful display of one attribute cannot leak the others onto the element. The link-scale chain button stays panel-state (it is a UI-only flag, not an element attribute). The read seams are the per-port live overrides: Rust build_live_panel_overrides (workspace/dock_panel.rs), Swift strokePanelLiveOverrides (Sources/Interpreter/StrokePanelSync.swift), reference sync_stroke_panel_from_selection (workspace_interpreter/effects.py), each gated by an element→panel test.

The attribute groups — a group is the set of attributes one field owns, and is a single attribute except where that is impossible:

Panel field Writes
weight the stroke width, taken from the weight input's committed value. This is the ONLY field that reads a panel-committed width, which is what makes it impossible for any other edit to disturb the element's weight.
cap the line cap
join the line join
miter_limit the miter limit
align_stroke the stroke alignment
dashed, dash_1, gap_1, dash_2, gap_2, dash_3, gap_3, dash_align_anchors the whole dash pattern and the anchor-alignment flag. Necessarily wide: a dash array cannot be written a slot at a time, so any dash-family edit re-derives the pattern from the panel's dash fields (empty when the dashed toggle is off).
start_arrowhead the start arrowhead shape
end_arrowhead the end arrowhead shape
start_arrowhead_scale the start arrowhead scale, and only the start scale
end_arrowhead_scale the end arrowhead scale, and only the end scale
arrow_align the arrowhead alignment
profile, profile_flipped the element's width points only; the stroke itself is untouched. The width points are also re-derived on a weight edit, because the profile scales with the weight.
link_arrowhead_scale nothing. The chain is a UI-only flag; toggling it must not push an undo step that changes nothing.

The two arrowhead scales are deliberately separate groups. Grouping them looks tempting because the link-scales chain moves them together, but the chain does that by committing the sibling field, which applies through that field's own group. Sharing one group bought nothing and cost an unlinked scale edit stamping the panel's sibling scale over the element's own.

Because the apply is field-scoped, every write into a stroke key must be attributed to the key it actually wrote — including writes made by another field's commit behaviour, like the chain mirror. A write blamed on the wrong field applies the wrong group.

"Preserved" means bit-for-bit, and the colour is the attribute where that bites. A stroke colour carries its colour space (RGB / HSB / CMYK) and its own alpha, so a colour that makes a round trip through 6-char hex comes back demoted to RGB, opaque, and quantised to 8 bits. A panel edit owns no part of the colour, so it must hand the element's colour object straight back — never re-derive it. The reference bridge did exactly that round trip on every apply, which made the reference the one implementation that broke its own preserve-the-rest clause; the corpus now carries a CMYK vector and an alpha-bearing vector so no implementation can silently demote a colour again.

Colour is not part of this. A colour pick changes the colour and nothing else, through the same preserve-the-rest rule (recolor_stroke / recolorStroke), and so does the fill/stroke swap (Shift+X, the widget arrow, the Color-panel button) — it swaps the two colours, and it sources them from the new-element defaults, never per element. The defaults are what decides nil (no fill / no stroke swaps too), and per-element sourcing breaks on the commonest case: a Line holds no fill, so its "own" fill colour is nothing and the swap would take its stroke away instead of recolouring it. Each port states this once — Rust AppState::swap_fill_stroke, Swift Controller.swapFillStrokeColors — and every call site routes there, so the keyboard path and the widget path cannot drift apart (they had: Swift's widget arrow sourced the selection while Shift+X sourced the defaults). Resetting to defaults (reset_fill_stroke) is the one action that legitimately replaces the whole set of stroke attributes, and workspace/actions.yaml enumerates what it resets.

The law is stated executably in the reference interpreter (workspace_interpreter/stroke_law.py, which holds the field → group table the ports mirror) and pinned across all three live implementations by test_fixtures/stroke_apply/panel_edit.json.

Per-app entry points (see the corresponding files for details — the names and locations mirror the Character panel wiring):

  • Rust (jas_dioxus): apply_stroke_panel_to_selection in src/workspace/app_state.rs; widget dispatch via the generic render_* helpers in src/interpreter/renderer.rs keyed on the enclosing panel_kind.
  • Swift (JasSwift): applyStrokePanelToSelection in Sources/Interpreter/Effects.swift, subscribed through the notify-panel-state-changed dispatcher.
  • Reference (workspace_interpreter): stroke_law.py states the law; apply_stroke_panel_to_selection in effects.py applies it, with subscribe_stroke_panel threading the changed key as the edited field.
  • OCaml (jas_ocaml): subscribe_stroke_panel in lib/interpreter/effects.ml. FROZEN at five-port-parity — it still holds the whole-rebuild law and is not being updated to match.
  • Python Qt app (jas): the stroke-panel subscription in jas/panels/. FROZEN at five-port-parity; it reaches the law only through the shared reference helper it calls.

Open follow-ups:

  • Inside / outside stroke alignment on closed paths requires a geometric offset at render time; canvases currently approximate it with center alignment.
  • Variable-width profile rendering (taper_*, bulge, pinch) is data-model only for some canvases; the full width-varying renderer is a separate task.
  • The 15 arrowhead shapes exist as marker references; the full per-shape SVG marker set needs to land before every shape renders on every canvas.

Banked by the STROKEWIDTH council (2026-07-24) — decided, deferred, and written down here so the decision does not live only in a commit message:

  • Per-element swap-colour sourcing. The fill/stroke swap sources both colours from the new-element defaults, in every port. Sourcing them per element (so a mixed selection swaps each element's own two colours) is a plausible future feature, but it needs an answer for elements that hold only one of the two — a Line has no fill, and swapping its stroke away to nothing is not what the arrow promises. Revisit with the multi-selection story, not before.
  • reset_fill_stroke_defaults still replaces whole attributes. Reset is the one action that legitimately does; workspace/actions.yaml enumerates exactly what it resets. Left as intended semantics, not an oversight — if it ever grows a "reset colours only" mode, that is a new action, not a change to this one.
  • Display-vs-apply sync. The panel shows the selection's real weight / cap / join through a per-render PULL (Rust build_live_panel_overrides, Swift strokePanelLiveOverrides) — but a push-style sync_stroke_panel_from_selection also survives in the reference (writing panel state) and in Swift (writing the flat globals, with no production caller). Rust deleted its copy, which additionally mutated the new-element default — a display path must never do that. Two shapes for one job: unifying on the pull is the intended direction and wants its own pass.
  • The profile stamp on a multi-selection. A profile / weight edit re-derives width points for the whole selection from ONE profile width: the committed weight for a weight edit, else the FIRST selected element's width. With mixed widths selected, the later elements get the first element's profile scale. Correct fix is per-element re-derivation; needs a decision on what the panel's profile picker means across a mixed selection.
  • The Character-panel clobber. DONE — CHARPANEL, 2026-07-25. Both applies now take the committed edited field and write only its attribute group, preserving the rest from the element per element, off a shared corpus (test_fixtures/character_apply/panel_edit.json) and a reference law + bridge (workspace_interpreter/character_law.py, effects.apply_character_panel_to_selection). It carried one lesson back for this list: with sixteen attributes over eighteen fields, three groups turn out to be fed by more than ONE panel field, and a field-scoped apply must read those groups' sibling fields from the ELEMENT — reading them from panel state destroyed the element's attribute (an Underline click erasing a line-through). The Stroke law's own multi-field family, the dash pattern, is safe from that only because every dash / gap slot round-trips through the panel's live pull; if that pull is ever narrowed, the same sibling rule applies. See transcripts/CHARACTER.md §The sibling rule.
  • Next panel in line: PARAGRAPH. apply_paragraph_panel_to_selection / applyParagraphPanelToSelection still rebuild every paragraph attribute from panel state on any write — one match arm away from the Character apply in the same files — and both ports mitigate it with precisely the pattern CHARPANEL condemned: a view-layer paragraphPanelLiveOverrides push in Swift's commitPanelWrite, a sync_paragraph_panel_from_selection in Rust. The fix is the same field-scoped move plus the sibling rule for its own multi-field groups (the seven alignment radios collapsing to one text_align / text_align_last pair; bullets / numbered_list sharing jas_list_style). Banked in CHARACTER.md's follow-ups too.
  • Swift's icon_button effects: not every write notifies. A YAML button's effects: reach the apply only through the effects that fire the notify_panel_state_changed hook — set: (per written key) and set_panel_state: (per key). The others that touch panel state, select: chief among them, write straight through store.setPanel and only bump the render version, so a panel-state write made that way lands without applying. No Stroke-panel control uses one today (its buttons all commit keyed values), which is why nothing is broken; the gap is in the generic widget layer and closing it means every panel-state-writing effect naming the field it wrote.
  • Rust set_app_state_field has no stroke_dash_align_anchors arm. A YAML set: { stroke_dash_align_anchors: ... } reaches neither the panel field nor any apply in the Rust port, while Swift's strokeRenderKeys fires it. Same decision surface as the global-set: apply question in this list; fix the two together.