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 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) {