All skills
lassejlv avatar

/build-gpui-apps

@52f4cfd
by Lasselassejlv/skills50 stars
1

Build, scaffold, refactor, debug, review, and validate native Rust desktop applications with GPUI. Recommend GPUI Kit and ask before adopting it; use gpui_kit imports after agreement, or preserve the chosen upstream GPUI stack. Includes the merged GPUI Kit component and design skills, full application/Base/Component/Shell documentation, coding and design guides, state, actions, async, input, accessibility, motion, themes, native integration, packaging, auto updates, testing, and production delivery. Use paper-to-gpui when the primary task is faithfully translating a selected Paper.design frame into an existing view.

Use this Skill: https://skilld.dev/gh/lassejlv/skills/build-gpui-apps

This session only. Nothing lands on disk.

referencesgpui-kitupstreamshellperformance.md

≈4.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Performance

The script is not in the frame is the claim the runtime is built on. This page is what follows from it: once a repaint no longer runs JavaScript, the cost that is left has a shape small enough to write down.

script cost  =  how often a View is invalidated  ×  what describing that View costs

Neither factor is the display refresh rate by itself. A window that repaints at 120 Hz does not run the View's JavaScript merely because more frames are presented; a View nobody has invalidated runs no script render. The left factor depends on when you call cx.notify() and on other invalidations such as a theme change; the right one is how much interface sits behind one call. Display refresh rate is a ceiling or target for scheduling, not a promise that an idle window redraws continuously.

Everything below is one of those two, or a way of telling which is the problem.

Every View has its own Snapshot

GPUI Shell gives each JavaScript View a Snapshot of its own: the description that View's render produced, kept in Rust.

A View's Snapshot is reused until that View changes. When GPUI does request another frame, it can use that Snapshot without running the View's JavaScript render. This says nothing by itself about how often an idle window presents a frame, or about the CPU and GPU work in the rest of that frame.

the View changed  ──▶  render()  ──▶  a new Snapshot  ──▶  frame
the View did not  ─────────────────▶  the Snapshot it has  ──▶  frame

Snapshots are per View, not per window. A window holding a hundred Views holds a hundred Snapshots, and each one is invalidated on its own:

What happens What runs
Watchlist calls cx.notify() Watchlist.render, and nothing else
The parent calls cx.notify() The parent's render. Each child answers the frame from its own Snapshot
this.chart.set_props({ symbol }) That child's update and render. The parent is not rebuilt
A child of a child calls cx.notify() That child's render. Invalidation does not travel upward
The theme changes Every View, because a Snapshot bakes in the colours it was built with
<img class="architecture-light" src="https://gpui-kit.com/shell-view-invalidation-light.svg" alt="A window drawn as nested Views: a sidebar, a watchlist holding four rows that are Views of their own, a chart, and a detail pane holding two more. Three phases repeat. A price ticks and only the MSFT row is marked as running its script, while every other View replays the description it already published. The list reorders and the watchlist itself runs while its four rows do not, because a parent records a handle per child rather than the child's description. The theme changes and every View runs at once, because a Snapshot bakes in the colours it was built with."> <img class="architecture-dark" src="https://gpui-kit.com/shell-view-invalidation-dark.svg" alt="A window drawn as nested Views: a sidebar, a watchlist holding four rows that are Views of their own, a chart, and a detail pane holding two more. Three phases repeat. A price ticks and only the MSFT row is marked as running its script, while every other View replays the description it already published. The list reorders and the watchlist itself runs while its four rows do not, because a parent records a handle per child rather than the child's description. The theme changes and every View runs at once, because a Snapshot bakes in the colours it was built with.">

Split a large View into small ones

A View is rebuilt whole. There is no partial rebuild inside one: if a View's description is four hundred nodes, any change rebuilds all four hundred, however small the change was.

That is what makes a large View expensive. Everything it draws shares one Snapshot, so the data that changes most often invalidates the parts that never change along with it. In a market terminal, one price moving re-describes the chart, the sidebar and the order book too — not because they changed, but because they sit inside the same View.

Splitting is the fix. Give each part that changes on its own a View of its own with cx.new, and a change reaches one Snapshot instead of all of them:

import { View } from "gpui-kit";

export default class Terminal extends View {
  init(props, cx) {
    this.sidebar = cx.new(Sidebar);
    this.watchlist = cx.new(Watchlist, { symbols: props.symbols });
    this.chart = cx.new(PriceChart, { symbol: props.symbols[0] });
    this.detail = cx.new(Detail, { symbol: props.symbols[0] });
  }

  render() {
    return h_flex()
      .child(this.sidebar)
      .child(this.watchlist)
      .child(v_flex().child(this.chart).child(this.detail));
  }
}

On the 40-row watchlist this page measures, describing the whole panel costs 0.315 ms and describing one row costs 0.012 ms — 361 nodes against 9.

Nesting itself costs almost nothing to weigh against that: a parent records a handle per child, not the child's description. So a complex interface is not, by itself, a performance problem. A large View is.

And splitting for performance means splitting into Views — not into plugins, applications or processes. Reach for a second application when you want a second authority, which is Capabilities, not when you want a second cache.

Notify what a reader can see

cx.notify() is the whole dependency system, and it means one specific thing: my description is out of date. It is not an event notification, and using it as one is the most common way to make a script expensive.

A feed handler is the usual case:

onQuote(quote, cx) {
  this.quotes.set(quote.symbol, quote);
  cx.notify();                  // every tick, including the ones nobody is looking at
}

If the View draws twenty symbols out of a subscription of two thousand, that notify pays for a full description of the panel on every tick of every symbol it does not draw. The fix is a condition, not a faster render:

onQuote(quote, cx) {
  this.quotes.set(quote.symbol, quote);
  if (this.visible.has(quote.symbol)) cx.notify();
}

Three rules follow from the same idea:

  • Invalidate the View that changed. State that belongs to one child should live on that child and be notified there, rather than on the parent that mounts it.
  • Several notifications before the next frame can share one script render. See below. The handlers and their other work still run, so condition notifications on changes the View actually displays.
  • From the host, cx.notify() and ScriptView::refresh are different requests. A bare notify repaints the description that already exists. If Rust changed state the script reads through a HostModule, the description is stale and only refresh says so. See Hosting.

What notify does, and what coalesces it

cx.notify() rebuilds nothing. It sets a flag on the View saying its description may be stale, and asks GPUI to draw. The rebuild happens later, inside the frame, and only if the flag is still set.

So every notify between two frames collapses into one render — whether they came from three event handlers, from a task in a loop, or from the host:

notify  notify  notify  ──▶  one frame  ──▶  one render()

Setting a flag three times is setting it once. Nothing is dropped: all three handlers ran and all three changed state; what they share is the single rebuild that follows.

Coalescing bounds script renders to at most one per View per frame. For example, if a window presents 120 frames in a measured second while a feed delivers 1,000 notifications, that View can render at most 120 times in those frames. It does not make the 1,000 handlers free, establish an actual 120 FPS rate, or imply that every display refresh produces a frame.

The runtime adds no separate script-render throttle to tune. GPUI's scheduling coalesces these requests into the next frame it processes; how soon that frame is presented depends on the window, platform, and workload.

What the cache costs in memory

A View holds two descriptions: the one it published, and the one it replaced. The second is kept a moment longer because an event can still be dispatched against a frame that has already been superseded, and the handlers that frame needs belong to that older description.

There is no third. Publishing a new description drops the oldest, and dropping it retires the callbacks registered with it. So the ceiling is two descriptions per live View, and nothing accumulates with time: a View that has re-rendered a million times holds exactly what a View that rendered twice holds. Closing a panel drops its View, and both of its descriptions go with it.

This is the other reason to split a large View rather than fear splitting: a hundred small Views hold a hundred small pairs, which together are the same interface described twice — not a hundred times.

Frame rate and presentation latency are different failures

Two things can be wrong with a running interface, and only one of them shows up as FPS:

Rendering FPS          is the frame smooth?
State → presentation   how long after state changes does the reader see it?

Missing a cx.notify() may leave the displayed data stale without causing a dropped frame. If some other activity causes frames, GPUI can keep using the last published description, and a frame-rate counter can look healthy while the data is wrong. If nothing requests a frame, the window may simply remain still. The stale data becomes visible only after another invalidation refreshes that View; frame-rate measurements alone cannot detect the missing notification.

Symptom Which number is wrong Usual cause
The window stutters during repaints even though script state is unchanged Frame time / FPS Frame work may be too large, for example layout, paint or virtual-list item work; see the measurement
The window stutters while a feed is running FPS and invalidation One boundary being rebuilt too often, too large, or both
The window is smooth and the data is late Presentation latency A notify that was skipped, deferred behind an await, or issued as a host cx.notify() where refresh was meant

Diagnose them separately. An FPS reading that never dropped is not evidence that invalidation is correct.

Reading the counters

The runtime counts the two events apart, and the host can read them with runtime.read_metrics() — see Watching what it costs for the API and the delta-against-a-baseline pattern that turns them into per-second rates.

Reading The question it answers
script_renders() How often JavaScript ran. Follows cx.notify(), reloads and theme changes — never frames
materializations() How often a dirty Snapshot became elements. Clean window frames reuse the cached GPUI subtree
mean_script_render() What one description costs, host calls included
mean_native() How much of that was inside HostModule functions rather than describing
slowest_script_render() The worst single build in the run
frame_script_calls() Entries into the VM from the frame path — virtual list item renderers and dock chrome handlers, which are the only two
structure_repeat_rate() Of the rebuilds that had a predecessor, what fraction described the same shape — see below

What the shape of a reading says:

  • script_renders per second far above the rate the data actually changes — a notify is firing on things the reader cannot see. Condition it.
  • script_renders reasonable, mean_script_render high — the boundary is too large. Split the View.
  • mean_native most of mean_script_render — the cost is in the host functions the description calls, not in the description. Read them once into fields before render, not per node.
  • slowest_script_render far above the mean — one build is paying for something the rest are not: a collection materialized on first render, or a rarely-taken branch that describes far more than the common one. A mean that drifts as a whole is system load instead.

Repeated cx.theme() calls do not repeatedly cross the native snapshot boundary. The runtime synchronizes a lightweight theme revision before a description starts; components then share one frozen JavaScript object until the semantic tokens or appearance change. Reading the theme in each component is therefore a cache lookup, not a reason to serialize the palette again.

Where the Snapshot cache stops

The Snapshot removes the cost of no change. It does not remove the cost of a small change.

A Snapshot holds structure and values together:

StockRow
├── Symbol("AAPL")
├── Price("230.42")
└── Change("+1.42%")

When the price becomes 230.51, the structure is identical and only one leaf differs — but a new description is the only way to say so, so the whole View is described again: every div(), every .gap(), every .bg(), every crossing into Rust. That is the dirty-render path, and on a fast feed it is the one that runs.

<img class="architecture-light" src="https://gpui-kit.com/shell-change-cost-light.svg" alt="Three lanes, bars to the same scale. Nothing the View reads changed: no bar, no script runs, the frame replays the description already published. A value changed, which is what happens today: the whole panel is described again at 0.315 milliseconds, however small the change. The same change with the row a retained View of its own: 0.012 milliseconds, about a twenty-sixth as long, because nine nodes are described instead of 361."> <img class="architecture-dark" src="https://gpui-kit.com/shell-change-cost-dark.svg" alt="Three lanes, bars to the same scale. Nothing the View reads changed: no bar, no script runs, the frame replays the description already published. A value changed, which is what happens today: the whole panel is described again at 0.315 milliseconds, however small the change. The same change with the row a retained View of its own: 0.012 milliseconds, about a twenty-sixth as long, because nine nodes are described instead of 361.">

The lever is the one this page opens with: shrink the boundary that has to be rebuilt. On the watchlist above, describing the whole panel costs 0.315 ms and describing one row costs 0.012 ms — 361 nodes against 9. Putting the row behind a View of its own is what turns the first number into the second, and it is available today.

structure_repeats() and structure_changes() are how you check that the boundary is doing what you think. They count how often a rebuild produced the same shape as the description it replaced, differing only in the values inside it. A panel reporting a low rate is worth knowing about on its own: something in it is changing structure when you thought only a number was.

Documentation license: original prose and illustrations for which GPUI Kit holds licensing rights are also offered under CC BY 4.0. When copying or adapting, credit GPUI Kit, link the source (https://gpui-kit.com/shell/performance) and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples and software source use Apache-2.0; third-party material retains its terms; existing Apache-2.0 permissions remain.

Bundled from GPUI Kit. Documentation prose: CC BY 4.0; code examples: Apache-2.0. Changes: documentation links localized, asset URLs made absolute, and this attribution added.

Source: SKILL.md on GitHub

No alerts3d3 checks · Risk SAFE
  • Gen Agent Trust Hub3d

    The skill provides comprehensive documentation and reference material for building native desktop applications using Rust and GPUI. No security risks, prompt injections, or malicious behaviors were identified. External dependencies originate from reputable organizations in the Rust ecosystem.

  • Socket3d

    No alerts

  • Snyk3d

    Risk: LOW · No issues

Signed by skilld at 52f4cfd. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub yesterday.

Activeupdated 4 days ago

README badge

README badge for lassejlv/skills/build-gpui-apps