From 311dd4821a33976cf5246a4e6bb5c6cf8ba8daa2 Mon Sep 17 00:00:00 2001 From: Colorfingers Date: Sun, 3 May 2026 13:18:44 -0500 Subject: [PATCH 1/2] Scope UI clean and CSS re-application to subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled changes that stop one event from re-laying-out the whole document. Pure perf — no UX or API removed. ui.Clean now walks only the receiver's subtree instead of always walking from rootUI. Cross-element layout, scissor, and render have no sibling dependencies (children read only their own layout and ancestor scissors, both unchanged when cleaning a subtree), so a subtree clean produces correct output without touching unrelated panels. The per-frame Manager.update path still calls Clean on root UIs, so root.Clean still covers the whole tree when needed. The unused rootUI helper is removed. cleanIfNeeded now walks down to the topmost dirty element on each branch and cleans only that subtree, instead of force-cleaning from root whenever any descendant is dirty. setDirtyInternal cascades dirty downward but never upward, so the topmost dirty element on each branch covers every dirty element exactly once. Stylizer.ApplyStylesToElement is added (clears + re-applies CSS rules only inside target's subtree) and the Doc.* mutators that previously re-styled the whole document now scope to the affected element: SetElementId, SetElementClasses, ChangeElementParent, DuplicateElement, DuplicateElementToParent, DuplicateElementRepeat (and the WithoutApplyStyles variant that despite its name was applying), and insertElementAt. Doc.ApplyStyles, the document-load setup, the window-resize handler, and RemoveElement still use the doc-wide path since those are genuinely document-affecting or one-shot. Caveat: scoped ApplyStylesToElement is correct under the engine's current selector grammar (id, class, tag, pseudo, attribute condition, descendant). If sibling combinators (`+`, `~`) are added later, the scope must widen accordingly. Discovered while profiling drag-drop in a heavily-populated editor: dropping a model was triggering 18 separate clean calls covering ~1500 elements across multiple roots, because every Doc.* mutator marked every element in the document dirty. With these three changes a drop cleans only the affected subtrees. --- src/engine/ui/markup/css/reader.go | 80 +++++++++++++++++++ src/engine/ui/markup/document/html_parser.go | 37 ++++++--- .../markup/document/html_style_interfaces.go | 5 ++ src/engine/ui/ui.go | 43 ++++------ 4 files changed, 127 insertions(+), 38 deletions(-) diff --git a/src/engine/ui/markup/css/reader.go b/src/engine/ui/markup/css/reader.go index 08099c54a..a8a8c477c 100644 --- a/src/engine/ui/markup/css/reader.go +++ b/src/engine/ui/markup/css/reader.go @@ -222,3 +222,83 @@ func (z Stylizer) ApplyStyles(s rules.StyleSheet, doc *document.Document) { } } } + +// ApplyStylesToElement re-evaluates CSS for `target` and its descendants +// only. Selector matching itself still scans the whole stylesheet against +// the whole document (the matcher is rules→elements oriented), but the +// clear/apply phases skip any element not in the subtree, so the dirty +// cascade and the next-frame Clean stay scoped. +// +// Correct under the engine's current selector grammar (id, class, tag, +// pseudo, attribute condition, descendant). If sibling combinators (`+`, +// `~`) are added later, callers that previously relied on the doc-wide +// behavior will need to widen their scope. +func (z Stylizer) ApplyStylesToElement(s rules.StyleSheet, doc *document.Document, target *document.Element) { + if target == nil { + return + } + inSubtree := make(map[*ui.UI]struct{}) + var walk func(e *document.Element) + walk = func(e *document.Element) { + if e == nil || e.UI == nil { + return + } + inSubtree[e.UI] = struct{}{} + for _, c := range e.Children { + walk(c) + } + } + walk(target) + for i := range doc.Elements { + e := doc.Elements[i] + if _, ok := inSubtree[e.UI]; !ok { + continue + } + e.Stylizer.ClearRules() + for j := range e.UIEventIds { + for k := range e.UIEventIds[j] { + e.UI.RemoveEvent(j, e.UIEventIds[j][k]) + } + } + } + cssMap := CSSMap(make(map[*ui.UI][]rules.Rule)) + for _, group := range s.Groups { + if group.MediaQuery.IsValid() { + switch group.MediaQuery.Key { + case "screen": + case "max-width": + v := helpers.NumFromLength(group.MediaQuery.Value, z.Window) + if int(v) <= z.Window.Width() { + continue + } + default: + continue + } + } + for _, sel := range group.Selectors { + if len(sel.Parts) == 1 { + applyDirect(sel.Parts[0], group.Rules, doc, cssMap) + } else if len(sel.Parts) > 1 { + applyIndirect(sel.Parts, group.Rules, doc, cssMap) + } + } + } + cleanMapDuplicates(cssMap) + for _, e := range doc.Elements { + if _, ok := inSubtree[e.UI]; !ok { + continue + } + if rs, ok := cssMap[e.UI]; ok { + applyToElement(rs, e) + } + } + for _, elm := range doc.Elements { + if _, ok := inSubtree[elm.UI]; !ok { + continue + } + if inlineStyle := elm.Attribute("style"); inlineStyle != "" { + group := s.ParseInline(inlineStyle, z.Window) + applyToElement(group.Rules, elm) + } + } +} diff --git a/src/engine/ui/markup/document/html_parser.go b/src/engine/ui/markup/document/html_parser.go index 494096eb1..b86225b97 100644 --- a/src/engine/ui/markup/document/html_parser.go +++ b/src/engine/ui/markup/document/html_parser.go @@ -729,7 +729,7 @@ func (d *Document) SetElementClassesWithoutApply(elm *Element, classes ...string func (d *Document) SetElementClasses(elm *Element, classes ...string) { d.SetElementClassesWithoutApply(elm, classes...) elm.UI.Layout().ClearStyles() - d.stylizer.ApplyStyles(d.style, d) + d.stylizer.ApplyStylesToElement(d.style, d, elm) } // ApplyStyles will go through and apply styles to all elements within the @@ -738,13 +738,25 @@ func (d *Document) SetElementClasses(elm *Element, classes ...string) { // styles of many elements at the same time, then apply styles after. func (d *Document) ApplyStyles() { d.stylizer.ApplyStyles(d.style, d) } +// ApplyStylesToElement re-evaluates CSS only for `elm` and its descendants, +// leaving the rest of the document untouched. Use this whenever a single +// element's id/class/parent/structure changes; doc-wide ApplyStyles dirties +// every element and forces the per-frame Clean to walk the whole tree. +// +// Caveat: the engine's selector matcher currently only supports descendant +// composition (no `+` adjacent-sibling, no `~` general-sibling). If sibling +// selectors are added later, this scope must widen accordingly. +func (d *Document) ApplyStylesToElement(elm *Element) { + d.stylizer.ApplyStylesToElement(d.style, d, elm) +} + // DuplicateElement will create a duplicate of a given element, nesting it under // the same parent as the given element (at the end). If you wish to just // duplicate an element and use one of the Insert functions, then use // Element.Clone followed by an Insert function instead func (d *Document) DuplicateElement(elm *Element) *Element { cpy := d.DuplicateElementWithoutApplyStyles(elm) - d.stylizer.ApplyStyles(d.style, d) + d.stylizer.ApplyStylesToElement(d.style, d, cpy) return cpy } @@ -788,17 +800,18 @@ func (d *Document) DuplicateElementToParent(elm, parent *Element) *Element { } d.appendElement(cpy) d.ChangeElementParentWithoutApply(cpy, parent) - d.stylizer.ApplyStyles(d.style, d) + d.stylizer.ApplyStylesToElement(d.style, d, cpy) return cpy } // DuplicateElementRepeat is the same as [DuplicateElement], but will duplicate -// the element a specified number of times. This is an optimization to avoid -// calling [ApplyStyles] on each duplicated element and instead call it at the -// end, after all copies are created. +// the element a specified number of times. Style application is scoped per +// duplicate so the rest of the document is not dirtied. func (d *Document) DuplicateElementRepeat(elm *Element, count int) []*Element { elms := d.DuplicateElementRepeatWithoutApplyStyles(elm, count) - d.stylizer.ApplyStyles(d.style, d) + for _, c := range elms { + d.stylizer.ApplyStylesToElement(d.style, d, c) + } return elms } @@ -808,7 +821,9 @@ func (d *Document) DuplicateElementRepeatWithoutApplyStyles(elm *Element, count elms[i] = elm.Clone(elm.Parent.Value()) d.appendElement(elms[i]) } - d.stylizer.ApplyStyles(d.style, d) + for _, c := range elms { + d.stylizer.ApplyStylesToElement(d.style, d, c) + } return elms } @@ -827,12 +842,12 @@ func (d *Document) SetElementIdWithoutApplyStyles(elm *Element, id string) { func (d *Document) SetElementId(elm *Element, id string) { defer tracing.NewRegion("Document.SetElementId").End() d.SetElementIdWithoutApplyStyles(elm, id) - d.ApplyStyles() + d.stylizer.ApplyStylesToElement(d.style, d, elm) } func (d *Document) ChangeElementParent(child, parent *Element) { d.ChangeElementParentWithoutApply(child, parent) - d.ApplyStyles() + d.stylizer.ApplyStylesToElement(d.style, d, child) } func (d *Document) ChangeElementParentWithoutApply(child, parent *Element) { @@ -944,5 +959,5 @@ func (d *Document) insertElementAt(elm *Element, parent *Element, index int) { if !d.isElementInDocument(elm) { d.appendElement(elm) } - d.stylizer.ApplyStyles(d.style, d) + d.stylizer.ApplyStylesToElement(d.style, d, elm) } diff --git a/src/engine/ui/markup/document/html_style_interfaces.go b/src/engine/ui/markup/document/html_style_interfaces.go index 257c99ec2..7d577a21a 100644 --- a/src/engine/ui/markup/document/html_style_interfaces.go +++ b/src/engine/ui/markup/document/html_style_interfaces.go @@ -42,4 +42,9 @@ import ( type Stylizer interface { ApplyStyles(s rules.StyleSheet, doc *Document) + // ApplyStylesToElement re-applies CSS rules only to `target` and its + // descendants. Implementations must clear and re-add rules + event + // handlers in the subtree without touching elements outside it, so a + // per-element class/id/parent change does not dirty the whole document. + ApplyStylesToElement(s rules.StyleSheet, doc *Document, target *Element) } diff --git a/src/engine/ui/ui.go b/src/engine/ui/ui.go index aaf1a9d8b..d0f2f2c3e 100644 --- a/src/engine/ui/ui.go +++ b/src/engine/ui/ui.go @@ -262,27 +262,18 @@ func (ui *UI) SetDirty(dirtyType DirtyType) { ui.setDirtyInternal(dirtyType) } -func (ui *UI) rootUI() *UI { - defer tracing.NewRegion("UI.rootUI").End() - root := &ui.entity - var rootUI *UI = FirstOnEntity(root) - for root.Parent != nil { - if pui := FirstOnEntity(root.Parent); pui != nil { - root = root.Parent - rootUI = pui - } else { - break - } - } - return rootUI -} - func (ui *UI) Clean() { defer tracing.NewRegion("UI.Clean").End() if ui.flags.dontClean() { return } - root := ui.rootUI() + // Clean only this element's subtree. Cross-element layout/scissor/render + // have no sibling dependencies — children read only their own layout and + // ancestor scissors, both of which are unchanged when cleaning a subtree. + // Callers that need a full-document clean should invoke Clean on the + // document root; the per-frame Manager.update path does exactly that via + // cleanIfNeeded on root UIs. + root := ui tree := []*UI{root} var createTree func(target *engine.Entity) createTree = func(target *engine.Entity) { @@ -545,23 +536,21 @@ func (ui *UI) layoutChanged(dirtyType DirtyType) { func (ui *UI) cleanIfNeeded() { defer tracing.NewRegion("UI.cleanIfNeeded").End() - if ui.anyChildDirty() { - ui.Clean() - } -} - -func (ui *UI) anyChildDirty() bool { - defer tracing.NewRegion("UI.anyChildDirty").End() + // Walk the tree top-down. At the first dirty element on each branch, + // Clean that subtree and stop descending into it (Clean already covers + // the descendants since dirty cascades downward via setDirtyInternal). + // Subtrees with no dirty elements are never touched, so an unrelated + // panel does not re-layout when a single sibling's data changes. if ui.dirtyType != DirtyTypeNone { - return true + ui.Clean() + return } for i := range ui.entity.Children { cui := FirstOnEntity(ui.entity.Children[i]) - if cui != nil && cui.anyChildDirty() { - return true + if cui != nil { + cui.cleanIfNeeded() } } - return false } func (ui *UI) updateFromManager(deltaTime float64) { From 4d410a5259ff1447284030b2ac71644f13792031 Mon Sep 17 00:00:00 2001 From: Colorfingers Date: Sun, 3 May 2026 13:24:15 -0500 Subject: [PATCH 2/2] Add .claude to .gitignore Keeps Claude Code's local working directory out of the repo. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 925c8adee..e77fc677f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ # Test binary, built with `go test -c` *.test +# Claude +.claude + # Output of the go coverage tool, specifically when used with LiteIDE *.out