Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 80 additions & 0 deletions src/engine/ui/markup/css/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
37 changes: 26 additions & 11 deletions src/engine/ui/markup/document/html_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
5 changes: 5 additions & 0 deletions src/engine/ui/markup/document/html_style_interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
43 changes: 16 additions & 27 deletions src/engine/ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading