# Windows and tabs

A window is a root view you hand to `cx.open_window`. This page covers what you can ask of the
window itself, then tabs, which come in two kinds: window tabs, where each tab is a window, and
in-window tabs, where the tabs are one view.

> **Example: Tabs.** Source in [examples/tabs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/tabs). Live on the web and captured on macOS, Linux and Windows in the docs app.


## Opening a window

```rust
pub fn app() -> Application {
    Application::new(nd::manifest!())
}

pub fn open(cx: &mut App) -> WindowHandle<Counter> {
    cx.open_window(WindowOptions::main("main").title("Hello"), |_window, cx| cx.new(|_cx| Counter { count: 0 }))
}
```

<sub>From [examples/hello/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/hello/src/lib.rs), line 35.</sub>


`WindowOptions::main(id)` takes an id and these methods:

| Method | Effect |
| --- | --- |
| `.title(..)` | the title; on a native tab it is the tab's label, and `cx.set_window_title` changes it later |
| `.size(..)`, `.min_size(..)` | initial and minimum content size |
| `.titlebar(Titlebar::Custom)` | content runs under a transparent title bar and you draw the row |
| `.kind(..)` | `Normal`, `Sheet { parent }` or `Settings` |
| `.tab_group(..)` | open as a tab of a group, see below |

Sheets and the settings window have helpers so you do not build the options by hand.
`window.open_sheet(cx, build)` opens a window-modal sheet on macOS and a dialog with a scrim
elsewhere:

```rust
fn open_settings(&mut self, _: &OpenSettings, window: &mut Window, cx: &mut Context<Self>) {
    window.open_sheet(cx, |window, cx| cx.new(|cx| SettingsSheet::new(window, cx)));
}
```

<sub>From [examples/notes/src/app.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/notes/src/app.rs), line 120.</sub>


`open_settings(cx, build)` opens the settings window, or brings the open one forward and
returns `None`. On macOS it is a preference-style window and the app menu's Settings item
(Cmd-,) opens it when the app binds that key.

```rust
/// Opens the settings window, or brings it forward.
pub fn open_settings_window(cx: &mut App) -> Option<WindowHandle<Settings>> {
    open_settings(cx, |_, cx| cx.new(|_| Settings { tab: "general".into(), density: 0, size: 15. }))
}
```

<sub>From [examples/chrome/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/chrome/src/lib.rs), line 185.</sub>


## Several windows

Open as many as you like; each `open_window` call is an independent OS window with its own root
view. Windows share the app's entities, so state shared between them is an `Entity` you clone
into each root. The pacing example opens two:

```rust
fn main() -> nd::Result<()> {
    pacing::app().run(|cx| {
        for name in ["pacing-a", "pacing-b"] {
            pacing::open(cx, name);
        }
    })
}
```

<sub>From [examples/pacing/src/main.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/pacing/src/main.rs), line 1.</sub>


Close with `cx.close_window(id)`. A user close goes to `Window::on_should_close` first when the
window has one, see [held closes](#held-closes).

## Monitors and frame pacing

Each window runs its own display clock at the refresh rate of the display it is on. Drag one
window to a 240 Hz monitor and the other stays at 144 Hz. `window.frame_time()` gives the
clock's `FrameTime`: the frame count, when the last frame was presented and when the one being
prepared will be. Sample animations at `window.now()`, which is the target time, and they stay
smooth at any rate.

An idle window requests no frames. To animate, call `cx.notify()` during render so the next
frame happens, as the pacing example does:

```rust
// Rendering again next frame keeps the window animating at its display's rate.
cx.notify();
let time = window.frame_time();
let hz = if time.interval() > 0. { 1. / time.interval() } else { 0. };
v_flex()
    .size_full()
    .items_center()
    .justify_center()
    .gap_1()
    .child(text(self.name).text_size(px(20.)))
    .child(text(format!("frame {}", time.frame)).tabular_nums())
    .child(text(format!("{hz:.0} Hz display")).tabular_nums())
```

<sub>From [examples/pacing/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/pacing/src/lib.rs), line 14.</sub>


Only occluded or minimised windows are throttled. A visible window that is not focused, such as
a lyrics window on the second monitor, keeps its display's rate while it animates.

`ND_PACING=1` makes the GPUI host print `nd-pacing:` lines with the measured rate of each
window. The macOS host uses a display link per window. The Windows per-monitor path has not
run on a machine with two refresh rates yet, see [STATUS.md](https://github.com/FormalSnake/NativeDesktop/blob/main/docs/plan/STATUS.md).

## Window controls and drag regions

The close, minimise and zoom controls are the OS's. You choose where they sit.

```rust
.child(window_controls().at(point(px(16.), px(10.))))
```

<sub>From [examples/hello/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/hello/src/lib.rs), line 25.</sub>


- `window_controls().at(point(x, y))` pins the traffic lights at window coordinates.
- `window_controls().embedded()` takes a slot in layout and the backend moves the real buttons
  onto it every layout pass, keeping hover, menu and accessibility. Notes puts one at the
  leading edge of its toolbar.
- With no arguments the controls stay where the OS puts them.

On Linux the controls are drawn only when the compositor leaves decorations to the client and
lists the action. Under Hyprland nothing is drawn and nothing takes space.

`drag_region()` is a `div` whose background moves the window: pointer-down starts the OS move
and a double click zooms on macOS and maximises elsewhere. Buttons and other interactive
children keep their clicks.

### Titlebar insets

With `Titlebar::Custom` you draw the title row, and `titlebar()` reserves the space the OS
controls need: leading on macOS, trailing on Windows, whichever side the client-decorated Linux
window uses, none under Hyprland. The row drags the window where it is empty.

```rust
// Pattern override: a custom titlebar even on macOS. `titlebar()` reserves the OS
// controls' side and size (traffic lights left on macOS, caption buttons right on
// Windows, nothing on Hyprland), so no margin is tuned by hand.
.child(
    titlebar()
        .title("Overrides")
        .trailing(
            // Per platform: glass bezel on macOS, accent style on Windows, pill on Linux.
            button("share", "Share")
                .icon(Icon::Share)
                .dispatch(Share)
                .on_macos(|b| b.bezel(Bezel::Glass))
                .on_windows(|b| b.accent())
                .on_linux(|b| b.rounded_full().px_4())
                .on_web(|b| b.outline()),
        )
        // Coordinates instead of the default slot, macOS only.
        .on_macos(|t| t.controls(window_controls().at(point(px(18.), px(20.))))),
)
```

<sub>From [examples/overrides/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/overrides/src/lib.rs), line 23.</sub>


To lay out your own row, `window.titlebar_insets()` returns the `leading`, `trailing` and
`height` the backend measured, and `.titlebar_insets()` on a `div` pads it by them. Do not tune
a margin by hand; the values change with the OS release.

## Window tabs

Windows that name the same group show as tabs of one window: `NSWindow` tabbing on macOS, a
drawn tab bar in one window elsewhere. The group is read when the window opens, and groups with
different names never merge.

```rust
let options = WindowOptions::main("tab").title("New Tab").size(size(px(900.), px(600.))).tab_group(GROUP);
let browser = browser.clone();
let handle = cx.open_window(options, move |window, cx| {
    let new_tab = browser.clone();
    window.on_new_tab(move |_, cx| {
        open_tab(cx, &new_tab);
    });
    let closing = browser.clone();
    window.on_should_close(move |window, cx| {
        // The app drops the tab's page and closes the window itself; returning false keeps
        // the platform from closing it a second time.
        close_tab(cx, &closing, window.id());
        false
    });
```

<sub>From [examples/tabs/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/tabs/src/lib.rs), line 105.</sub>


The tabs example is a browser. Each tab is a `TabWindow` holding a `Page`, and the app keeps a
`Browser` entity with the list of tabs. What the handlers do:

- `on_new_tab` runs when the user presses the tab bar's `+`, and you open another window in the
  same group.
- `on_should_close` decides what a user close does (the close button, Cmd-W, a drawn tab's
  close). Cmd-T and Cmd-W reach the app as the actions you bind and put in the menus.
- `cx.window_command(id, WindowCommand::ToggleTabOverview)` shows or hides the group's overview,
  which is macOS's Show All Tabs:

```rust
.on_action(cx.listener(|_: &mut TabWindow, _: &ShowAllTabs, window, cx| {
    cx.window_command(window.id(), nd::backend::WindowCommand::ToggleTabOverview);
}))
```

<sub>From [examples/tabs/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/tabs/src/lib.rs), line 90.</sub>


### Held closes

A tab's close is held until the app agrees. The handler returns `false` to keep the platform
from closing the window, drops the tab's page, then closes the window itself.

```rust
/// Drops the tab's page, then closes its window.
pub fn close_tab(cx: &mut App, browser: &Entity<Browser>, window: WindowId) {
    let removed = browser.update(cx, |b, _| {
        let at = b.tabs.iter().position(|t| t.window == window)?;
        let tab = b.tabs.remove(at);
        Some(tab.page)
    });
    if let Some(page) = removed {
        let number = page.read(cx).number;
        browser.update(cx, |b, _| b.closed.push(number));
    }
    cx.close_window(window);
}
```

<sub>From [examples/tabs/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/tabs/src/lib.rs), line 130.</sub>


This order matters for a tab with a native surface in it. If the platform closed the window
first, a web view would be torn down before the app saw the close. Without a handler a close
request closes the window straight away.

### Moving tabs

Drag a tab out of the bar to make a new window, or into another window of the same group to
move it. A view moved to another window keeps its native nodes, so a `webview` does not reload.
Tabs from different groups refuse each other.

- macOS: the system's tab bar, drag-out and drag-in, `+` and Show All Tabs.
- GPUI backends: one OS window per group with a drawn libadwaita tab bar and an overview.
  Dragging out opens a new window on release. Hyprland cannot place that window at the release
  point, and tear-off is untested on a real Hyprland session. The overview cards have no
  thumbnails.
- Web: not available, the page is one canvas.

## Tabs inside a window

`tab_view(id)` is a tab strip over a stack of pages. `tab_strip(id)` is the strip alone, for when
you switch the content yourself. Selection is controlled: the strip reports `TabChanged` and you
pass the id back with `.selected(..)`.

```rust
tab_view("documents")
    .presentation(TabStyle::Strip)
    .add_button(true)
    .selected(self.document.clone())
    .h(px(140.))
    .tabs(documents)
    .on_select(cx.listener(|this, e: &TabChanged, _, cx| {
        this.document = e.id.clone();
        cx.notify();
    }))
    .on_close(cx.listener(|this, e: &TabCloseRequested, _, cx| {
        this.documents.retain(|d| *d != e.id);
        if this.document == e.id {
            this.document = this.documents.first().cloned().unwrap_or_default();
        }
        this.last = format!("Closed {}", e.id).into();
        cx.notify();
    }))
    .on_add(cx.listener(|this, _: &TabAddRequested, _, cx| {
        let name: SharedString = format!("Document {}", this.next).into();
        this.next += 1;
        this.documents.push(name.clone());
        this.document = name;
        cx.notify();
    })),
```

<sub>From [examples/gallery/src/layout.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/gallery/src/layout.rs), line 87.</sub>


- The pages share one grid cell and unselected ones stay mounted and hidden, so the view measures
  as its tallest page, nothing below it moves when the selection changes, and page state
  survives. `.homogeneous(false)` mounts only the selected page and takes its size.
- `TabStyle::Automatic` is a segmented control on macOS and a view switcher on GPUI backends.
  `TabStyle::Strip` draws closable document tabs with an optional `+` (`.add_button(true)`),
  and reports `on_close` and `on_add`. macOS has no in-window document tab bar, so there it is a
  segmented control, and closing a tab goes through your own command. For document tabs on
  macOS use window tabs.
- `tab_page(id, title)` takes `.icon(..)` and `.closable()`.

Settings windows that put their tabs in the toolbar do it with `toolbar().tab(..)`, shown in
[chrome](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/chrome/src/lib.rs).

## Split views

`split(id, Axis)` has two panes and a divider the user drags. It is an `NSSplitView` on macOS
and a drawn divider on GPUI backends.

```rust
split("body", Axis::Horizontal).ratio(0.3).first(sidebar).second(content).on_resize(listener)
```

The ratio is controlled. A drag reports `SplitResized { ratio }`; feeding the same ratio back
sets nothing. `split_pane()` wraps a pane to give it `min_size` and `max_size`, and a drag stops
there. On macOS split panes lay out again on the next run loop turn.

## Pane trees

For splits the user creates, `panes` is a pure model plus the elements that draw it. The model
is a binary tree of splits and leaves with one focused leaf, and every operation returns a value
equal to its input, sharing its `Rc`, when nothing changed. A native echo of a ratio the model
already holds therefore cannot start a render and save loop. Compare with `PaneTree::same`.

```rust
pub fn new(_cx: &mut Context<Self>) -> Self {
    let tree = panes::seed_panes(vec![SharedString::from("Editor"), SharedString::from("Preview")]);
    Self { tree, next: 1 }
}

fn split(&mut self, axis: panes::Axis) {
    let focused = self.tree.focused_id().clone();
    let name: SharedString = format!("Pane {}", self.next).into();
    self.next += 1;
    self.tree = panes::split_pane(&self.tree, &focused, axis, name, None);
}
```

<sub>From [examples/gallery/src/layout.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/gallery/src/layout.rs), line 125.</sub>


`seed_panes`, `split_pane`, `close_pane`, `focus_pane`, `focus_neighbor` and `set_pane_ratio`
cover the editing, and `serialize_panes` with `migrate_panes` persist a layout. `pane_tree`
draws a tree; your closure draws each leaf and is told whether it is the focused one.

```rust
pane_tree("panes", &self.tree, move |leaf, focused| {
    let id = leaf.id.clone();
    let weak = weak.clone();
    div()
        .size_full()
        .p_3()
        .bg(raised)
        .when(focused, |d| d.border_2().border_color(accent))
        .on_tap(move |_, _, cx| {
            weak.update(cx, |this, cx| {
                this.tree = panes::focus_pane(&this.tree, &id);
                cx.notify();
            });
        })
        .child(label(leaf.data.clone()))
        .into_any_element()
})
.on_resize(cx.listener(|this, e: &PaneResized, _, cx| {
    let next = panes::set_pane_ratio(&this.tree, &e.split, e.ratio);
    if !next.same(&this.tree) {
        this.tree = next;
        cx.notify();
    }
})),
```

<sub>From [examples/gallery/src/layout.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/gallery/src/layout.rs), line 167.</sub>


Two more views use the same discipline. `dock_view` renders tab stacks as leaves, with ops to
add, close, move and undock tabs and dock one panel against another, and keeps every tab
mounted. `tiles_view` places freeform tiles on a column grid and pushes others down on
collision. The gallery's Split view page runs the pane tree.
