GUI Panels
What you build
A small program that puts a web panel into the shared QAROS room, moves, resizes, minimizes and navigates it, and prints each distinct update the panel's subscription delivers. It then lists every panel in the session and closes its own.
What you will learn:
- how a panel's common name makes creation idempotent,
- how to subscribe to a panel's updates with
qaros::channel, and why the callback runs on a library thread, - why an update tells you that the panel changed, and how to skip the Hub's repeats,
- when it is safe to end a subscription, and the one rule that keeps ending it from deadlocking,
- what the Rust wrapper releases for you, and in which order.
Before you start
- Platform. The real runtime runs on Windows. On macOS and Linux you can run the example
against
qaros-stub, the in-memory stand-in library the binding's tests use; it behaves like a Hub for panels but shows nothing. - QAROS. Installed on the Hub machine, where the example runs, or a runtime folder named by
QAROS_RUNTIME_DIR(see How to run the examples). Joining as a local app starts the Hub when it is not running. - Joining. The example joins the Hub as a local app (Local app): click Allow in the QAROS approval prompt on the first run of the executable. Nothing is stored; later runs join without a prompt.1
- The latest stable Rust.
Run it
From the binding's workspace folder, on Windows:
# Join the Hub as a local app: click Allow in QAROS on the first run.
cargo run -p qaros-examples --bin gui_panel_operations
To load a runtime other than the one the default lookup finds, add --qaros-library=PATH after
the --, or set QAROS_DLL.
Outside Windows the example does not try the local-app join, so the stub run passes a code: any
code works except WRONG and
UNREACHABLE, which the stub refuses on purpose. On macOS:
cargo build -p qaros-stub
mkdir -p target/stub-run && cd target/stub-run
QAROS_DLL="$PWD/../debug/libqaros_stub.dylib" \
cargo run -p qaros-examples --bin gui_panel_operations -- ANY-CODE
The stub run stores gui_panel_operations.onboarding-id.txt in its folder, which a later run against QAROS from that folder would try to rejoin with: keep stub runs in a folder of their own (On macOS, against the stub). It prints (the Loading lines name your own path):
Loading qar-streaming-c from: .../target/stub-run/../debug/libqaros_stub.dylib
Loaded .../target/debug/libqaros_stub.dylib
qar_runtime_create: SUCCESS
qar_runtime_onboard: SUCCESS
GUI panel id: D9:AF:F4:85:71:EE:15:15:16:24:07:A6:2E:F3:0D:42
Update: position(0.20, 1.60, -1.00) size(1.20 x 0.70) state=Visible uri=''
Update: position(0.20, 1.60, -1.00) size(1.00 x 0.60) state=Visible uri=''
Update: position(0.20, 1.60, -1.00) size(1.00 x 0.60) state=Minimized uri=''
Update: position(0.20, 1.60, -1.00) size(1.00 x 0.60) state=Minimized uri='https://example.com/tutorial'
Received 4 updates, printed 4.
- Panel id: D9:AF:F4:85:71:EE:15:15:16:24:07:A6:2E:F3:0D:42 name='Tutorial Panel' size(1.00 x 0.60) position(0.20, 1.60, -1.00) state=Minimized uri='https://example.com/tutorial'
qar_gui_panels_close_panel: SUCCESS
The stub reports one update per change, in order, before each call returns. A real Hub delivers
updates asynchronously, on its own schedule, and reports your own changes back to you, often
several times: a few changes can arrive as dozens of updates, most of them the same to the
millimeter, and an update can carry an older state than the one you set. That is why the program
prints only an update that differs from the last one it printed, and why the Update: lines need
not match the calls one to one. When some updates repeated the one before them, the Received
line goes on: printed <m>: the others matched the update printed before them. Against a Hub you
would see the panel appear in the room, move, shrink and minimize, then disappear when the program
closes it.
When no update arrives within 10 s of the changes, the line
No update of the panel arrived within 10 s of the changes. replaces the Update: lines. The
program still lists and closes the panel, then ends with error: no update of the panel arrived
and exit code 1.
Walkthrough
1. Get a session
Everything a panel does goes through a session: the panel is shared session state, so every peer
sees the same panel at the same place. The example uses the shared helper join_session: load
the library, create a runtime, rejoin with the stored id, else onboard with the code
(Onboarding and Rejoin), else join
as a local app (Local App Onboarding).
The CPU Rendering tutorial shows the helper.
let arguments = Arguments::parse();
let library = load_library()?;
let runtime = create_runtime(&library, None)?;
let session = join_session(
&library,
&runtime,
arguments.pairing_code.as_deref(),
Path::new("gui_panel_operations.onboarding-id.txt"),
"GUI Panel Peer",
)?;
What can go wrong:
ONBOARDING_SESSION_NOT_FOUNDfrom the rejoin is normal: the stored id has no slot on this machine. The helper goes on to the code, or to the local-app join.PAKE_ERRORmeans the code was wrong or expired. Read the current code and run again.ONBOARDING_HUB_UNREACHABLEis a real error: no Hub answered. With a code, the example finds only your own Hub, on the Hub machine, since it passes no Hub address. For the local-app join it is exit code 3: the join starts a Hub that is not running, so the Hub could not be started or reached.- A local-app join that is denied, or not answered within 120 s, ends with exit code 4 or 5, as
in
local_app_onboarding. - Not on Windows, without a stored id and without a code, the helper stops with "No persisted onboarding id".
2. Get or create the panel
A panel is identified by its common name, not by a handle. get_or_create returns the panel
with that name, creating it the first time, so the same call is safe on every start and a
restarted app reattaches to its panel instead of adding a second one. The id it returns derives
from the name. Use a DNS-style, app-scoped name and keep it stable across versions
(Custom GUI Panels).
let panels = session.gui_panels();
let mut config = GuiPanelConfig::new("tutorial-panel.examples.qaros");
config.display_name = Some("Tutorial Panel".to_string());
config.pose = Pose::from_position(Vec3::new(0.5, 1.5, -1.2));
config.size = GuiPanelSize {
width: 1.2,
height: 0.7,
..GuiPanelSize::default()
};
let panel_id = panels.get_or_create(&config)?;
println!("GUI panel id: {panel_id}");
The call does not say whether it created the panel or found it, so the program prints the id only. It closes its panel at the end, so a normal run creates it; after a run that ended early, it finds the one left behind.
The pose is the midpoint of the panel's top edge in room space, not its center; the surface
hangs below it and faces room -Z with the identity orientation
(Coordinate Systems).
GuiPanelSize also carries content_scale, which maps the web content onto the panel's meters;
..GuiPanelSize::default() keeps it at 1.
What can go wrong: the common name must be ASCII, and the runtime rejects any other. Put
non-ASCII text in display_name. A string with an interior NUL fails before the call with
Error::InvalidArgument.
3. Subscribe before you change anything
Other peers, other apps and users grabbing the panel all change it. A subscription tells you when. Subscribe before your own changes so you do not miss any.
let (forward_update, panel_updates) = qaros::channel();
let subscription = panels.subscribe_panel(panel_id, forward_update)?;
The callback runs on a library thread, possibly while your main thread is busy (the
"Callback threading" warning under
Blocking / async pairs). That is why
subscribe_panel takes a callback that is Send + Sync + 'static, and why the example passes the
forwarding half of qaros::channel(): the callback only sends a GuiPanelInfo snapshot into a
std::sync::mpsc channel, and your thread reads it when it is ready. The snapshot is an owned
copy, read from the borrowed C handle during the callback, so you may keep it.
Two rules for any callback you write yourself: do not call blocking QAROS functions from it, and keep it short. A callback that panics does not unwind into the C library; the wrapper catches the panic, logs an error and drops that event.
subscribe (all panels) works the same way. subscribe_panel filters to one panel.
4. Change the panel
All changes go through the session and the panel id.
panels.set_pose(panel_id, Pose::from_position(Vec3::new(0.2, 1.6, -1.0)))?;
panels.set_size(
panel_id,
GuiPanelSize {
width: 1.0,
height: 0.6,
..GuiPanelSize::default()
},
)?;
panels.set_state(panel_id, GuiPanelState::Minimized)?;
panels.navigate_to_uri(panel_id, "https://example.com/tutorial")?;
set_posemoves the top-edge midpoint.set_sizechanges the physical size and the content scale together.set_stateswitches betweenVisible,MinimizedandHidden. To remove a panel, useclose(step 7).navigate_to_uripoints the panel at web content.
What can go wrong: GUI_PANEL_INVALID_ID means no panel has that id, for example because another
peer closed it. Call get_or_create again if your app still needs it.
5. Receive the updates
The example waits up to 10 s for the first update, then reads the channel until no update has arrived for half a second. The Hub can take a few seconds to deliver the first update, so a quiet period alone could end before anything arrived. When no update comes within 10 s, the example says so and, after closing the panel, exits with 1: a program that follows updates and sees none should not report success.
let mut received_count = 0;
let mut printed_count = 0;
let mut last_printed: Option<GuiPanelInfo> = None;
for update in updates_until_quiet(&panel_updates) {
received_count += 1;
// The Hub repeats updates; print only one that differs from the last one printed.
if last_printed
.as_ref()
.is_some_and(|last| same_panel_update(last, &update))
{
continue;
}
printed_count += 1;
println!(
"Update: position({:.2}, {:.2}, {:.2}) size({:.2} x {:.2}) state={:?} uri='{}'",
update.pose.position.x,
update.pose.position.y,
update.pose.position.z,
update.size.width,
update.size.height,
update.state,
update.content_uri
);
last_printed = Some(update);
}
if received_count == 0 {
println!(
"No update of the panel arrived within {} s of the changes.",
FIRST_UPDATE_TIMEOUT.as_secs()
);
}
let skipped_repeats = if printed_count < received_count {
": the others matched the update printed before them"
} else {
""
};
println!("Received {received_count} updates, printed {printed_count}{skipped_repeats}.");
The wait is updates_until_quiet in the examples' shared library, which
App Volumes uses too:
/// The Hub delivers the first update on its own schedule, which can take a few seconds after the
/// subscription. Ten seconds leaves room for that and still ends a run in which no update comes.
pub const FIRST_UPDATE_TIMEOUT: Duration = Duration::from_secs(10);
/// After the first update, how long the wait lasts without one more.
pub const UPDATE_QUIET_PERIOD: Duration = Duration::from_millis(500);
/// The updates `updates` receives: the first within [`FIRST_UPDATE_TIMEOUT`], then each next one
/// until none has arrived for [`UPDATE_QUIET_PERIOD`]. Empty when no update arrived in time.
pub fn updates_until_quiet<Update>(
updates: &Receiver<Update>,
) -> impl Iterator<Item = Update> + '_ {
let mut deadline = Instant::now() + FIRST_UPDATE_TIMEOUT;
std::iter::from_fn(move || {
let update = updates
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.ok()?;
deadline = Instant::now() + UPDATE_QUIET_PERIOD;
Some(update)
})
}
The example prints an update only when it differs from the last one it printed: the Hub repeats
updates, and a repeat tells you nothing new. A repeat need not be equal to the bit. Its pose and
size can differ from the earlier report below any precision you print or see, so ==, which
compares the floats exactly, takes most repeats for changes. Compare at the precision your app
cares about. The example allows a millimeter for pose and size, and compares the state and the
address exactly:
/// Whether two updates show the same panel: pose and size at the precision of `same_pose`, state
/// and address exactly.
fn same_panel_update(first: &GuiPanelInfo, second: &GuiPanelInfo) -> bool {
same_pose(&first.pose, &second.pose)
&& nearly_equal(first.size.width, second.size.width, SAME_LENGTH_METERS)
&& nearly_equal(first.size.height, second.size.height, SAME_LENGTH_METERS)
&& nearly_equal(
first.size.content_scale,
second.size.content_scale,
SAME_FACTOR,
)
&& first.state == second.state
&& first.content_uri == second.content_uri
}
The tolerances are in the shared library too:
/// The Hub reports a value back that can differ from the one you set, and from its own earlier
/// report, below the precision anyone sees. Compared exactly, such an echo looks like a change.
/// So compare at the precision your app cares about. These examples print centimeters; a
/// millimeter is far below what a viewer notices.
pub const SAME_LENGTH_METERS: f32 = 0.001;
/// For each component of a unit quaternion: 0.0001 is about a hundredth of a degree.
pub const SAME_ROTATION: f32 = 0.0001;
/// For a factor such as a scale: one part in a thousand.
pub const SAME_FACTOR: f32 = 0.001;
pub fn nearly_equal(first: f32, second: f32, tolerance: f32) -> bool {
(first - second).abs() <= tolerance
}
/// Whether two poses are the same at the precision above.
pub fn same_pose(first: &Pose, second: &Pose) -> bool {
nearly_equal(first.position.x, second.position.x, SAME_LENGTH_METERS)
&& nearly_equal(first.position.y, second.position.y, SAME_LENGTH_METERS)
&& nearly_equal(first.position.z, second.position.z, SAME_LENGTH_METERS)
&& nearly_equal(first.orientation.x, second.orientation.x, SAME_ROTATION)
&& nearly_equal(first.orientation.y, second.orientation.y, SAME_ROTATION)
&& nearly_equal(first.orientation.z, second.orientation.z, SAME_ROTATION)
&& nearly_equal(first.orientation.w, second.orientation.w, SAME_ROTATION)
}
A real application does not wait for a quiet period. It reads the channel from its own loop (a UI
frame, a render loop) with try_recv or try_iter, and never blocks a callback waiting for it.
Treat an update as news that the panel changed, not as an instruction. Expect your own changes to
come back, repeated and sometimes older than what you set: skip an update that matches the last
one you handled at the precision you care about, and do not take an update for a user's action,
since it may be the echo of your own call. The channel stays connected until the session ends, not
when the subscription ends; see step 7.
6. List the panels
list is a snapshot of every panel in the session, built with the C API's count-then-fetch
enumeration. The wrapper destroys each C handle after reading it.
for panel in panels.list()? {
println!(
"- Panel id: {} name='{}' size({:.2} x {:.2}) position({:.2}, {:.2}, {:.2}) state={:?} uri='{}'",
panel.id,
panel.display_name,
panel.size.width,
panel.size.height,
panel.pose.position.x,
panel.pose.position.y,
panel.pose.position.z,
panel.state,
panel.content_uri
);
}
The list can be stale the moment it returns. Use it at startup or for display; use the subscription to follow changes.
7. End the subscription, then close the panel
subscription.cancel()?;
panels.close(panel_id)?;
println!("qar_gui_panels_close_panel: SUCCESS");
subscription.cancel() ends the subscription and reports a failed cancel as an error. Dropping
the Subscription ends it too and logs a failure instead. Either way, when the call returns, a
callback that was running on another thread has finished, and no callback starts afterwards.
That guarantee is what makes ending a subscription safe, and it comes with one rule:
:::warning Never end a subscription while holding a lock its callback takes
Ending a subscription waits for a running callback. If your thread holds a Mutex the callback
is waiting to lock, the callback never finishes and the cancel never returns. Release the lock
before you cancel or drop the subscription. For the same reason, do not end subscription A from a
callback of subscription B while a callback of B can be ending A.
:::
The channel pattern sidesteps the rule: the forwarding callback takes no lock of yours.
Ending a subscription from inside its own callback is allowed: the call does not wait for the
callback it is made from. The Subscription value is not Send, though, so in Rust you would
have to hand it to the callback through your own synchronization; keeping cancellation on the
thread that subscribed is simpler.
close closes and removes the panel, for every peer, since panels are shared. A later
get_or_create with the same name creates it again.
Ownership and teardown
| Value | Owns | Released |
|---|---|---|
library | the initialized C library | when the last value that needs it is gone: qar_library_destroy. The DLL itself stays loaded until the process exits. |
runtime | the QarRuntime | after the last session of it |
session | the QarSession and every subscription's callback state | on drop: qar_session_handle_destroy, then the callback state |
panels | nothing: a borrow of the session | cannot outlive session (the borrow checker enforces it) |
subscription | the subscription's cancel token | on cancel() or drop: cancels delivery |
panel_updates | the receiving end of the channel | disconnects when the session is destroyed |
GuiPanelInfo | an owned snapshot | ordinary Rust value |
What the wrapper guarantees:
- The C teardown order holds in any drop order. Each handle keeps its parent alive (session keeps runtime, runtime keeps library), so the C order session, runtime, library always holds. Locals drop in reverse order of declaration, which is that order anyway.
- Callback state outlives every callback. The boxed callback goes to C as
user_stateand is freed only afterqar_session_handle_destroyreturns, so even a subscription you never cancel is safe. The cost: a session holding many short-lived subscriptions keeps their callbacks in memory until it ends. - Destroying the session ends every subscription that is still live.
What it does not guarantee:
- It cannot stop your callback from deadlocking: the lock rule above is yours to keep.
- It does not close panels. A panel is shared session state, not a handle your process owns, so
dropping the session does not call
close. Close the panels your app no longer needs. - Handles are not
Send. Keeplibrary,runtime,sessionandpanelson one thread; only callbacks cross threads.
Troubleshooting
| Symptom | Cause | What to do |
|---|---|---|
No persisted onboarding id in 'gui_panel_operations.onboarding-id.txt' | Not on Windows, no code, and no id file in this folder | Pass the current pairing code, or run from the folder that holds the file |
Exit code 4 or 5 after joining the QAROS Hub on this machine as a local app | The approval prompt was denied, closed or not answered within 120 s | Run again and click Allow |
qar_runtime_onboard failed with QAR_STATUS_PAKE_ERROR | The code was wrong or expired (codes live about 10 s) | Read the current code and run again |
QAR_STATUS_ONBOARDING_HUB_UNREACHABLE | No Hub answered; with a code, the example finds only your own Hub, on the Hub machine | Start QAROS and run again (Troubleshooting) |
QAR_STATUS_GUI_PANEL_INVALID_ID from a setter | The panel was closed, possibly by another peer | get_or_create again |
get_or_create fails for a name with accents or other non-ASCII text | Common names must be ASCII | Keep the name ASCII; put the text in display_name |
The program hangs in cancel() or at the end of a scope | A callback is waiting for a lock the cancelling thread holds | Release the lock before ending the subscription |
No update of the panel arrived within 10 s of the changes., exit code 1 | The Hub delivered no update of the panel within 10 s after the changes | Check that the Hub is still running and run again. In your app, read updates as they come instead of waiting for them a fixed time |
| Many more updates received than printed | The Hub reports your own changes back, repeated | Expected; skip repeats as the example does |
| A runtime cannot be loaded | Default lookup found nothing | Install QAROS, set QAROS_RUNTIME_DIR, or pass --qaros-library=PATH |
From C
If you know the C tutorial:
| C | Rust |
|---|---|
example_join_session (rejoin, else code, else local app) | qaros_examples::join_session (rejoin, else code, else local app) |
QarGuiPanelInit, qar_gui_panel_init_default() | GuiPanelConfig::new(common_name) |
qar_gui_panels_get_or_create | GuiPanels::get_or_create |
qar_gui_panels_update_pose, _change_size, _set_state, _navigate_to_uri | set_pose, set_size, set_state, navigate_to_uri |
qar_query_gui_panels_count + qar_query_gui_panels + the qar_gui_panel_get_* getters + qar_gui_panel_handle_destroy | GuiPanels::list, returning Vec<GuiPanelInfo> |
qar_gui_panels_subscribe_panel_updates with a callback, user_state and a QarCancelToken | subscribe_panel(id, callback), returning a Subscription |
qar_cancel_token_cancel | Subscription::cancel or dropping it |
qar_gui_panels_close_panel | GuiPanels::close |
qar_session_handle_destroy, then the runtime, then qar_library_destroy | dropping the values |
QarResult checked with log_result | qaros::Result, propagated with ? |
Not in the safe API: qar_gui_panels_update_visible_to after creation (set
GuiPanelConfig::visible_to when you create the panel), stream panels and panel gestures. Reach
them through library.raw_api() with the session's as_raw(), as the
GUI Panels page's Rust tab shows.
Next
- App Volumes: the 3D box your app renders into, and how panels and volumes differ.
- Custom GUI Panels: visibility per peer and integration patterns.
- API Conventions: ownership, callbacks and results in every language.