Skip to main content
Version: 0.9.0 (next)

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_FOUND from 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_ERROR means the code was wrong or expired. Read the current code and run again.
  • ONBOARDING_HUB_UNREACHABLE is 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_pose moves the top-edge midpoint.
  • set_size changes the physical size and the content scale together.
  • set_state switches between Visible, Minimized and Hidden. To remove a panel, use close (step 7).
  • navigate_to_uri points 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​

ValueOwnsReleased
librarythe initialized C librarywhen the last value that needs it is gone: qar_library_destroy. The DLL itself stays loaded until the process exits.
runtimethe QarRuntimeafter the last session of it
sessionthe QarSession and every subscription's callback stateon drop: qar_session_handle_destroy, then the callback state
panelsnothing: a borrow of the sessioncannot outlive session (the borrow checker enforces it)
subscriptionthe subscription's cancel tokenon cancel() or drop: cancels delivery
panel_updatesthe receiving end of the channeldisconnects when the session is destroyed
GuiPanelInfoan owned snapshotordinary 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_state and is freed only after qar_session_handle_destroy returns, 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. Keep library, runtime, session and panels on one thread; only callbacks cross threads.

Troubleshooting​

SymptomCauseWhat to do
No persisted onboarding id in 'gui_panel_operations.onboarding-id.txt'Not on Windows, no code, and no id file in this folderPass 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 appThe approval prompt was denied, closed or not answered within 120 sRun again and click Allow
qar_runtime_onboard failed with QAR_STATUS_PAKE_ERRORThe code was wrong or expired (codes live about 10 s)Read the current code and run again
QAR_STATUS_ONBOARDING_HUB_UNREACHABLENo Hub answered; with a code, the example finds only your own Hub, on the Hub machineStart QAROS and run again (Troubleshooting)
QAR_STATUS_GUI_PANEL_INVALID_ID from a setterThe panel was closed, possibly by another peerget_or_create again
get_or_create fails for a name with accents or other non-ASCII textCommon names must be ASCIIKeep the name ASCII; put the text in display_name
The program hangs in cancel() or at the end of a scopeA callback is waiting for a lock the cancelling thread holdsRelease the lock before ending the subscription
No update of the panel arrived within 10 s of the changes., exit code 1The Hub delivered no update of the panel within 10 s after the changesCheck 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 printedThe Hub reports your own changes back, repeatedExpected; skip repeats as the example does
A runtime cannot be loadedDefault lookup found nothingInstall QAROS, set QAROS_RUNTIME_DIR, or pass --qaros-library=PATH

From C​

If you know the C tutorial:

CRust
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_createGuiPanels::get_or_create
qar_gui_panels_update_pose, _change_size, _set_state, _navigate_to_uriset_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_destroyGuiPanels::list, returning Vec<GuiPanelInfo>
qar_gui_panels_subscribe_panel_updates with a callback, user_state and a QarCancelTokensubscribe_panel(id, callback), returning a Subscription
qar_cancel_token_cancelSubscription::cancel or dropping it
qar_gui_panels_close_panelGuiPanels::close
qar_session_handle_destroy, then the runtime, then qar_library_destroydropping the values
QarResult checked with log_resultqaros::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.

Footnotes​

  1. The example also takes a Hub's pairing code as its argument; it passes no Hub address, so the code finds only your own Hub, on the Hub machine (a Hub elsewhere needs its host and port, HubAddress): cargo run -p qaros-examples --bin gui_panel_operations -- <PAIRING-CODE>. It then stores the onboarding id it gets in gui_panel_operations.onboarding-id.txt in the folder you run cargo from, and rejoins with that id on every later run (Onboarding and Rejoin). ↩