DevTools
guise::devtools is Safari's Web Inspector, aimed at the gpui app it is running
inside. Eight tools across the top — Elements, Network, Sources, Timelines,
Storage, Layers, Logs, Audit — a tree beside a details sidebar, and a drawer
that drops the log over whatever else you were looking at.
use guise::prelude::*;
// once at startup
DevToolsState::new().init(cx);
// anywhere you want it: a pane, a drawer, its own window
let devtools = cx.new(DevTools::new);
That is the whole integration for the panels that inspect the UI.
An inspector records the window it is rendered in, and only that one. The recorder is per thread rather than per window, so an inspector claims the current frame when it renders and every other window your app draws that frame is skipped — put the inspector in the window whose components you want to see. Two inspectors in two windows take that claim from each other every frame and both come up empty; there is one tree, and showing each of them a tree of both windows would be worse than showing neither. Try it:
cargo run -p guise-ui --example devtools # opens on Elements
cargo run -p guise-ui --example devtools -- network
Where the data comes from
The split between what the inspector knows and what it is told is the whole design, and it is worth understanding before wiring anything up.
| Panel | Source |
|---|---|
| Elements, Layers, Styles | Introspection. Components report themselves; the tree, the bounds and the style declarations are read back out of what actually rendered. |
| Logs, Network, Storage, Timelines | Reported by the host. guise never opens a socket, so your code calls log, network_begin, storage_set, measure. |
| Sources | The #[track_caller] locations on the tree, read off disk. |
| Audit | Computed here, from the tree, against the library's own rules. |
This is the same arrangement guise::ai uses: the component owns the
display, the host owns the work.
Why it is called Logs and not Console
Half of Safari's Console tab is a JavaScript evaluator. A compiled binary has nothing to evaluate, so a prompt here would be a text field that cannot answer. The panel is named for the half that transfers.
Elements
The tree is the component hierarchy, not a wall of anonymous containers:
Button variant: filled, size: sm, foldable, one row per component.
It is an indented tree rather than the markup a browser prints, because these
are components built from builder calls, not tags: there is no attributes-versus
-children distinction to draw, <Button … /> would promise a model gpui does
not have, and a closing row says nothing the next row's indentation has not
already said while costing a container half the panel. Props read as a YAML flow
mapping, the way the Styles pane reads a declaration. Selecting a node fills the
sidebar.
- Styles — the element's own declarations, rendered as a CSS rule, with color swatches and the source location it was constructed at. Click the location in the Node pane to jump to Sources.
- Computed — the box model diagram (margin / border / padding / content, each edge labelled in pixels) plus every declaration sorted by name.
- Node — identity, geometry and the reported attributes.
The sidebar is read-only, and deliberately: a probe is a snapshot taken during prepaint, so writing to it would edit a copy and change nothing on screen.
How the tree gets built
gpui will tell you which element the pointer is over, but it will not enumerate
a tree — inspector_hitboxes is crate-private and holds one frame of whatever
was under the cursor. So guise records its own.
Every component tags its root element:
impl RenderOnce for Button {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
// ...
element
.probe("Button")
.attr("variant", self.variant.label())
.attr("size", self.size.label())
.attr_if("disabled", self.disabled)
}
}
probe wraps the element in a pass-through that pushes a node on the way into
prepaint and pops it on the way out. gpui prepaints depth-first, so the
push/pop pairs nest exactly like the element tree does.
Do the same in your own components and they appear in the tree beside the
library's. It costs one boolean check per element per frame while the inspector
is closed, and nothing is allocated in that state — attributes are dropped at the
setter, and attr_with defers a format! you would otherwise pay for on every
release-build frame.
| Method | Notes |
|---|---|
probe(name) |
The normal case. Snapshots the element's style, which is what fills the Styles sidebar. Needs Styled. |
probe_any(name) |
For a component that returns something already composed — a Field, a deferred(..) overlay. No style snapshot; the wrapped component reports its own. |
attr(name, value) |
A prop shown inline after the component's name. |
attr_with(name, || …) |
The same, but the value is only built while recording. |
attr_if(name, bool) |
A prop with no value, printed on its own when true — dimmed rather than dimmed: true. |
Logs
Levels, coalesced repeats, expandable detail rows, a source link per line, and a filter that searches all three.
guise::devtools::log(cx, LogLevel::Warning, "cache miss");
guise::devtools::log_record(
cx,
LogRecord::new(LogLevel::Error, "Failed to decode avatar.png")
.detail("bytes", "18420")
.detail("format", "png"),
);
log is #[track_caller], so the line knows where it came from without you
passing a location. Identical consecutive messages collapse into one row with a
counter rather than scrolling the useful history away.
Network
A sortable table whose last column is the timing waterfall, with a per-request sidebar of Headers / Cookies / Sizes / Timing / Preview.
Open the record when the request starts and settle it by id when it lands:
let id = guise::devtools::network_begin(
cx,
NetworkRecord::new("GET", "https://api.example.com/v1/items")
.kind(ResourceKind::Fetch)
.request_header("Accept", "application/json"),
);
if let Some(id) = id {
guise::devtools::network_update(cx, id, |record| {
record.state = RequestState::Finished;
record.status = Some(200);
record.transfer_size = 4_200;
record.resource_size = 11_800;
record.timings.response = Duration::from_millis(34);
});
}
A record shows as Pending for as long as it is in flight. network_update on an
id that has already been evicted does nothing, so a long-lived request cannot
panic the inspector.
Storage
The host names its own domains; the panel groups them under Safari's headings.
guise::devtools::storage_set(
cx,
StorageDomain::new("prefs", "app.preferences")
.kind(StorageKind::Local)
.entry(StorageEntry::new("theme", "dark"))
.entry(StorageEntry::new("window", "1280×820")),
);
Registering the same id again replaces the snapshot, so a host can publish on
every change without accumulating duplicates. StorageDomain::columns adds
columns beyond Key and Value — cookie attributes, record types — which each
entry fills in by name through StorageEntry::extra.
Timelines
Instrument bands laid out against one ruler, plus an event list.
// time an existing call without restructuring it
let index = guise::devtools::measure(cx, "reindex()", || reindex());
// or report a span you measured yourself
guise::devtools::timeline_event(
cx,
TimelineEvent::new(TimelineKind::Layout, "layout pass", elapsed),
);
The Frames band is measured by the inspector itself and is off until you press Record. That is not laziness: gpui paints on demand, so the gap between two frames of an idle window is however long it sat idle, which would report as a stall that never happened.
Sources
The files the tree's elements were constructed in, read off disk and shown with
line numbers around the target line. Paths from #[track_caller] are relative
to the workspace root, so they are resolved against the working directory and
each of its ancestors — which finds the checkout from anywhere inside it. When
the file is not there, the panel says so rather than guessing.
Audit
Rules that run over the recorded tree, worst first, each finding pointing at a node the Elements panel can select.
| Rule | Reports |
|---|---|
| Text contrast | Text on a background it fails WCAG's 4.5:1 against. |
| Hit target size | A control small in both directions — a wide row a pixel or two short of 24 is not the defect the rule is aimed at. |
| Collapsed container | Children, but zero width or height. Almost always a missing flex_1, w_full or min_h(0). |
| Overflow | A child painting outside its parent by more than a pixel. |
| Nesting depth | Deeper than 24 levels. |
Events
Everything the inspector can do alone, it does alone. These are the rest:
cx.subscribe(&devtools, |this, _devtools, event: &DevToolsEvent, cx| match event {
// A source location was clicked. Sources has already opened it; this is
// your chance to open it in a real editor instead.
DevToolsEvent::RevealSource(source) => this.open_in_editor(source),
DevToolsEvent::Dock(side) => this.move_inspector(*side, cx),
DevToolsEvent::Close => this.hide_inspector(cx),
DevToolsEvent::Picking(armed) => this.arm_picker(*armed, cx),
})
.detach();
Dock and Close are requests, not actions: guise cannot move or hide a panel
the host owns, so the buttons report and you decide.
Element picking
The crosshair arms the picker and emits DevToolsEvent::Picking(true). Hit
testing happens in the window, not in the panel, so the host forwards the point:
if devtools.read(cx).is_picking() {
devtools.update(cx, |devtools, cx| devtools.pick_at(event.position, cx));
}
pick_at selects the deepest recorded node containing that point, expands its
ancestors, scrolls the tree to it, and disarms. DevTools::selected_bounds gives the selection's bounds
back, for a host that wants to paint a highlight over its own window.
Cost
Nothing here is compiled out of release builds, because dead-code elimination
already handles that: an app that never constructs DevTools links none of it.
What an app does carry is the probe calls left in components, and those are
a boolean check while the inspector is closed. Recording turns on when a
DevTools is created and off when it is dropped.
The stores are rings — 1000 log lines, 1000 requests, 4000 timeline events by
default — so a long-running app cannot grow the inspector without bound. Change
them with DevToolsState::new().limits(Limits { .. }).
Every reporting call is a no-op when DevToolsState was never installed, so
instrumentation can be left in place unconditionally.