diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs
index dbcb65bd..824eb44a 100644
--- a/crates/ui/src/docking/chrome.rs
+++ b/crates/ui/src/docking/chrome.rs
@@ -32,6 +32,8 @@ pub struct ChromeStyle {
pub text: Color32,
pub weak_text: Color32,
pub accent: Color32,
+ /// Fill of a selected or hovered control, such as an icon rail's open item.
+ pub active_background: Color32,
pub font: FontId,
/// Mirror the title bar: buttons at the left, the title at the right.
pub right_to_left: bool,
@@ -39,7 +41,23 @@ pub struct ChromeStyle {
impl ChromeStyle {
pub fn from_ui(ui: &Ui) -> Self {
- Self::from_dock(&DockStyle::from_ui(ui))
+ Self::from_visuals(ui.visuals())
+ }
+
+ /// The colors [`Self::from_dock`] takes from [`DockStyle::from_visuals`], without building
+ /// the dock style's labels.
+ pub fn from_visuals(visuals: &egui::Visuals) -> Self {
+ Self {
+ background: visuals.window_fill,
+ stroke: visuals.window_stroke,
+ title_background: visuals.extreme_bg_color,
+ text: visuals.text_color(),
+ weak_text: visuals.weak_text_color(),
+ accent: visuals.selection.stroke.color,
+ active_background: visuals.widgets.active.bg_fill,
+ font: FontId::proportional(12.0),
+ right_to_left: false,
+ }
}
pub fn from_dock(style: &DockStyle) -> Self {
@@ -50,6 +68,7 @@ impl ChromeStyle {
text: style.text,
weak_text: style.inactive_text,
accent: style.accent,
+ active_background: style.active_background,
font: style.font.clone(),
right_to_left: style.right_to_left,
}
diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs
index f0eb6676..991d052d 100644
--- a/crates/ui/src/panes/mod.rs
+++ b/crates/ui/src/panes/mod.rs
@@ -5,13 +5,16 @@
//! `CentralPanel`; the document never becomes a panel. The app owns which panes are open and
//! where they are: the host takes the open list and each pane's [`PaneState`] every frame and
//! returns [`PaneRequest`]s for the app to run through its own commands. Docking chrome
-//! ([`crate::docking::chrome`]) draws the floating frames and title bars.
+//! ([`crate::docking::chrome`]) draws the floating frames and title bars. [`Rail`] draws an icon
+//! strip at one edge that opens one pane at a time.
use std::fmt::Debug;
use std::hash::Hash;
use std::ops::RangeInclusive;
-use egui::{Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Ui, UiBuilder, Vec2, pos2, vec2};
+use egui::{
+ Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Ui, UiBuilder, Vec2, WidgetInfo, WidgetType, pos2, vec2,
+};
use crate::docking::PanelLimits;
use crate::docking::chrome::{self, ChromeStyle, FloatingFrame, MoveStep, TitleBar};
@@ -20,6 +23,10 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
+mod rail;
+
+pub use rail::{RAIL_BUTTON, RAIL_WIDTH, Rail, RailItem, RailOutput, RailRequest};
+
/// A title dragged this far out of a docked pane floats it; a pane dropped this close to the
/// host's left or right edge docks there.
pub const DOCK_SNAP: f32 = 32.0;
@@ -136,6 +143,13 @@ pub trait PaneContent
{
fn closable(&self, _pane: &P) -> bool {
true
}
+ /// `false`: a docked pane draws no title bar (no title, ▾ menu, × or title drag), for bodies
+ /// that draw their own header. The body fills the pane, the resize edge stays and F6 still
+ /// reaches the pane. Close then comes only from the app. A floating pane always draws its
+ /// title bar, which moves it.
+ fn title_bar(&self, _pane: &P) -> bool {
+ true
+ }
/// App items, shown above the Move, Float and Close items of the ▾ menu.
fn menu(&mut self, _ui: &mut Ui, _pane: &P) {}
}
@@ -487,21 +501,40 @@ impl PaneHost {
Side::Bottom => egui::Panel::bottom(panel_id),
};
let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).stroke(ui.visuals().widgets.noninteractive.bg_stroke));
+ let title_bar = content.title_bar(&s.pane);
let shown = panel.exact_size(size).resizable(false).frame(frame).show(ui, |ui| {
let top = ui.max_rect();
- let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT));
- ui.allocate_rect(title, Sense::hover());
- let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests);
+ let (bar, body_top) = if title_bar {
+ let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT));
+ ui.allocate_rect(title, Sense::hover());
+ (Some(self.chrome(ui, title, s, content, style, focus, bounds, requests)), title.bottom())
+ } else {
+ (None, top.top())
+ };
// The pane reserves exactly its size and clips its body. egui cuts a panel wider
// than its exact size at its outer edge, so a wide body would shift a right or
// bottom pane's reserved rect inwards and the document would draw over the pane.
- let body = Rect::from_min_max(pos2(top.left(), title.bottom()), top.max);
+ let body = Rect::from_min_max(pos2(top.left(), body_top), top.max);
+ // Without a title bar, F6 focuses the body itself; Tab goes on into its widgets,
+ // which register after it.
+ let stop = (!title_bar).then(|| {
+ let stop = ui.interact(body, s.id.with("body"), Sense::focusable_noninteractive());
+ let title = content.title(&s.pane);
+ stop.widget_info(|| WidgetInfo::labeled(WidgetType::Panel, true, &title));
+ if focus {
+ stop.request_focus();
+ }
+ stop
+ });
let mut child = ui.new_child(UiBuilder::new().max_rect(body).layout(*ui.layout()));
child.set_clip_rect(body.intersect(ui.clip_rect()));
Frame::NONE.inner_margin(self.margin).show(&mut child, |ui| {
ui.set_min_width(ui.available_width());
content.body(ui, &s.pane);
});
+ if stop.as_ref().is_some_and(Response::has_focus) {
+ ui.painter().rect_stroke(body.shrink(1.0), 0.0, egui::Stroke::new(1.0, style.accent), egui::StrokeKind::Inside);
+ }
ui.allocate_rect(body, Sense::hover());
bar
});
@@ -514,7 +547,13 @@ impl PaneHost {
Side::Bottom => (SplitAxis::Vertical, Rect::from_x_y_ranges(rect.x_range(), rect.top() - 2.0..=rect.top() + 2.0)),
};
let handle_style = ResizeHandleStyle { hovered: style.accent.gamma_multiply(0.5), dragged: style.accent, focused: style.accent };
- let resize = ResizeHandle::new(s.id.with("resize"), axis, strip, &self.labels.resize, handle_style).show(ui);
+ // The edge's hit strip reaches over the document, whose widgets register after the host
+ // and would take presses there on the host's layer. A sublayer sits directly above it:
+ // over the document, under floating windows.
+ let edge_layer = LayerId::new(ui.layer_id().order, s.id.with("resize-layer"));
+ ui.ctx().set_sublayer(ui.layer_id(), edge_layer);
+ let mut edge_ui = ui.new_child(UiBuilder::new().layer_id(edge_layer));
+ let resize = ResizeHandle::new(s.id.with("resize"), axis, strip, &self.labels.resize, handle_style).show(&mut edge_ui);
let next = if resize.response.dragged() {
resize.response.interact_pointer_pos().map(|p| match side {
Side::Left => p.x - rect.left(),
@@ -533,13 +572,15 @@ impl PaneHost {
requests.push(PaneRequest::Resize(s.pane.clone(), next));
}
- if content.movable(&s.pane) {
+ if content.movable(&s.pane)
+ && let Some(bar) = &bar
+ {
// Docked at the host's left or right edge, the pane gets the width the side limits
// give its size: a bottom pane's height, not its width.
let (side_min, side_max) = self.limits(content, &s.pane, bounds.width());
let side_width =
s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(side_min).clamp(side_min, side_max);
- self.drag_out(ui.ctx(), &bar, s, edge, rect, side_width, bounds, memory, preview, style, requests);
+ self.drag_out(ui.ctx(), bar, s, edge, rect, side_width, bounds, memory, preview, style, requests);
}
rect
}
@@ -737,5 +778,7 @@ pub fn float_rect(saved: Option<[f32; 4]>, bounds: Rect, min: Vec2) -> Rect {
Rect::from_min_size(pos, size)
}
+#[cfg(test)]
+mod rail_tests;
#[cfg(test)]
mod tests;
diff --git a/crates/ui/src/panes/rail.rs b/crates/ui/src/panes/rail.rs
new file mode 100644
index 00000000..68a7bf89
--- /dev/null
+++ b/crates/ui/src/panes/rail.rs
@@ -0,0 +1,299 @@
+//! An icon rail: a vertical strip of icons, always shown at one edge, that opens one task pane at
+//! a time beside it.
+//!
+//! The rail stores nothing. The app passes its items and the pane it has open every frame, shows
+//! that pane with [`super::PaneHost`] ([`super::SidePolicy::OneSlot`], not movable), and runs the
+//! [`RailRequest`] a click returns.
+
+use std::fmt::Debug;
+use std::hash::Hash;
+
+use egui::{
+ Align, EventFilter, Frame, Id, ImageSource, Key, Layout, Margin, Modifiers, Rect, Response, Sense, Stroke, StrokeKind, Ui, WidgetInfo,
+ WidgetType, vec2,
+};
+
+use super::Edge;
+use crate::docking::chrome::ChromeStyle;
+
+/// Default width of the rail.
+pub const RAIL_WIDTH: f32 = 48.0;
+/// Default width and height of one item.
+pub const RAIL_BUTTON: f32 = 34.0;
+/// Room between two items.
+const SPACING: f32 = 4.0;
+/// Room between an item's edge and its icon.
+const ICON_INSET: f32 = 8.0;
+/// Radius of the marker dot.
+const MARKER_RADIUS: f32 = 3.0;
+/// Largest width or item size the rail accepts; larger or non-finite values fall back.
+const MAX_SIZE: f32 = 4096.0;
+
+/// One icon of the rail, standing for the pane it opens.
+#[derive(Clone, Debug)]
+pub struct RailItem
{
+ pub pane: P,
+ /// Painted tinted with the chrome's text colors, like docking's panel icons.
+ pub icon: ImageSource<'static>,
+ /// Tooltip and accessible name. Translate it in the app.
+ pub label: String,
+ /// `false`: drawn disabled, not focusable, and a click asks for nothing.
+ pub enabled: bool,
+ /// A small dot telling that the pane has content (comments exist, the document has
+ /// bookmarks). Drawn only while the pane is not open.
+ pub marker: bool,
+ /// The item's accessible description while the marker shows ("Has comments"). Translate it
+ /// in the app.
+ pub marker_label: Option,
+}
+
+impl RailItem
{
+ /// An enabled item without a marker.
+ pub fn new(pane: P, icon: ImageSource<'static>, label: impl Into) -> Self {
+ Self { pane, icon, label: label.into(), enabled: true, marker: false, marker_label: None }
+ }
+
+ pub fn enabled(mut self, enabled: bool) -> Self {
+ self.enabled = enabled;
+ self
+ }
+
+ pub fn marker(mut self, marker: bool) -> Self {
+ self.marker = marker;
+ self
+ }
+
+ pub fn marker_label(mut self, label: impl Into) -> Self {
+ self.marker_label = Some(label.into());
+ self
+ }
+}
+
+/// What a click on an item asks the app to do.
+#[derive(Clone, Debug, PartialEq)]
+#[non_exhaustive]
+pub enum RailRequest {
+ /// Open this pane in place of the open one.
+ Open(P),
+ /// Close this pane, which is the open one.
+ Close(P),
+}
+
+/// What [`Rail::show`] returns.
+#[derive(Clone, Debug, PartialEq)]
+#[non_exhaustive]
+pub struct RailOutput
{
+ /// The click of this frame, if any.
+ pub request: Option>,
+ /// The item holding keyboard focus.
+ pub focused: Option,
+ /// Where the rail was drawn.
+ pub rect: Rect,
+}
+
+/// Draws the icon rail. Build it every frame.
+pub struct Rail {
+ id: Id,
+ edge: Edge,
+ rtl: bool,
+ width: f32,
+ button: f32,
+ frame: Option,
+}
+
+impl Rail {
+ pub fn new(id: impl Into) -> Self {
+ Self { id: id.into(), edge: Edge::End, rtl: false, width: RAIL_WIDTH, button: RAIL_BUTTON, frame: None }
+ }
+
+ /// The edge the rail sits at. Default [`Edge::End`]. The rail is vertical, so
+ /// [`Edge::Bottom`] draws it at the end edge.
+ pub fn edge(mut self, edge: Edge) -> Self {
+ self.edge = edge;
+ self
+ }
+
+ /// Right-to-left interface: `Start` is the right edge, and the marker dot sits at the
+ /// item's top-left corner.
+ pub fn rtl(mut self, rtl: bool) -> Self {
+ self.rtl = rtl;
+ self
+ }
+
+ /// Width of the rail. Default [`RAIL_WIDTH`].
+ pub fn width(mut self, width: f32) -> Self {
+ self.width = if width.is_finite() { width.clamp(0.0, MAX_SIZE) } else { RAIL_WIDTH };
+ self
+ }
+
+ /// Width and height of one item. Default [`RAIL_BUTTON`]. The icon is 16 points smaller.
+ pub fn button_size(mut self, size: f32) -> Self {
+ self.button = if size.is_finite() { size.clamp(0.0, MAX_SIZE) } else { RAIL_BUTTON };
+ self
+ }
+
+ /// Frame of the rail. Default: the panel fill and an 8 pt margin above and below. The panel's
+ /// separator line draws the inner edge.
+ pub fn frame(mut self, frame: Frame) -> Self {
+ self.frame = Some(frame);
+ self
+ }
+
+ /// The widget id of `pane`'s item, to give it keyboard focus (`Memory::request_focus`), for
+ /// example when the app's F6 cycle reaches the rail.
+ pub fn item_id(&self, pane: &P) -> Id {
+ self.id.with(("rail-item", pane))
+ }
+
+ fn left(&self) -> bool {
+ matches!((self.edge, self.rtl), (Edge::Start, false) | (Edge::End | Edge::Bottom, true))
+ }
+
+ /// Draw the rail as the outermost panel on its edge: call it before [`super::PaneHost::show`]
+ /// (and after the app's top and bottom bars), so the open pane sits between the document and
+ /// the rail. `items` are drawn top-down and scroll in a short window; `foot` draws below
+ /// them, always visible (page navigation, zoom), and may draw nothing. `open` is the pane
+ /// the app has open.
+ pub fn show(self, ui: &mut Ui, items: &[RailItem], open: Option<&P>, foot: impl FnOnce(&mut Ui)) -> RailOutput
{
+ let style = ChromeStyle::from_ui(ui);
+ let panel_id = self.id.with("rail-panel");
+ let panel = if self.left() { egui::Panel::left(panel_id) } else { egui::Panel::right(panel_id) };
+ let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).inner_margin(Margin::symmetric(0, 8)));
+ let shown = panel.exact_size(self.width).resizable(false).frame(frame).show(ui, |ui| {
+ // The foot is a panel of its own, so that in a short window the items scroll instead
+ // of running into it.
+ egui::Panel::bottom(self.id.with("rail-foot")).frame(Frame::NONE).show_separator_line(false).resizable(false).show(ui, |ui| {
+ ui.with_layout(Layout::top_down(Align::Center), foot);
+ });
+ egui::ScrollArea::vertical()
+ .id_salt(self.id.with("rail-items"))
+ .auto_shrink([false, false])
+ .show(ui, |ui| {
+ ui.with_layout(Layout::top_down(Align::Center), |ui| {
+ ui.spacing_mut().item_spacing.y = SPACING;
+ self.items(ui, items, open, &style)
+ })
+ .inner
+ })
+ .inner
+ });
+ let (request, focused) = shown.inner;
+ RailOutput { request, focused, rect: shown.response.rect }
+ }
+
+ fn items(
+ &self,
+ ui: &mut Ui,
+ items: &[RailItem],
+ open: Option<&P>,
+ style: &ChromeStyle,
+ ) -> (Option>, Option) {
+ let mut request = None;
+ let mut focused = None;
+ let open = open.map(|pane| self.item_id(pane));
+ let mut drawn: Vec = Vec::new();
+ // Focus order for Up and Down: the enabled items with their place in `drawn`.
+ let mut enabled: Vec<(usize, Id, &P)> = Vec::new();
+ let mut focus_index = None;
+ // An item disabled while focused, by its place. egui drops a disabled widget's focus as
+ // it draws it, so this is read before.
+ let mut lost_focus = None;
+ for item in items {
+ let id = self.item_id(&item.pane);
+ // A pane listed twice would give two widgets one id.
+ if drawn.contains(&id) {
+ continue;
+ }
+ let place = drawn.len();
+ drawn.push(id);
+ let selected = open == Some(id);
+ let had_focus = ui.memory(|m| m.has_focus(id));
+ let response = ui
+ .add_enabled_ui(item.enabled, |ui| {
+ let (rect, _) = ui.allocate_exact_size(vec2(self.button, self.button), Sense::hover());
+ let response = ui.interact(rect, id, Sense::click());
+ response.widget_info(|| WidgetInfo::selected(WidgetType::Button, ui.is_enabled(), selected, &item.label));
+ if item.marker
+ && !selected
+ && let Some(label) = &item.marker_label
+ {
+ ui.ctx().accesskit_node_builder(id, |node| node.set_description(label.as_str()));
+ }
+ self.paint(ui, &response, item, selected, style);
+ response
+ })
+ .inner;
+ let response = if item.enabled { response.on_hover_text(&item.label) } else { response.on_disabled_hover_text(&item.label) };
+ if !item.enabled {
+ if had_focus {
+ lost_focus = Some(place);
+ }
+ continue;
+ }
+ if response.gained_focus() {
+ response.scroll_to_me(None);
+ }
+ if response.has_focus() {
+ focus_index = Some(enabled.len());
+ focused = Some(item.pane.clone());
+ }
+ enabled.push((place, id, &item.pane));
+ if response.clicked() {
+ request = Some(if selected { RailRequest::Close(item.pane.clone()) } else { RailRequest::Open(item.pane.clone()) });
+ }
+ }
+ if let Some(place) = lost_focus
+ && focus_index.is_none()
+ {
+ // Focus moves to the nearest enabled item below, else above, else leaves the rail.
+ let next = enabled.iter().find(|(p, ..)| *p > place).or_else(|| enabled.iter().rev().find(|(p, ..)| *p < place));
+ if let Some(&(_, next, pane)) = next {
+ ui.memory_mut(|m| m.request_focus(next));
+ focused = Some(pane.clone());
+ }
+ }
+ if let Some(index) = focus_index
+ && let Some(&(_, id, _)) = enabled.get(index)
+ {
+ // Up and Down move between the items, never out of the rail by egui's geometric focus.
+ ui.memory_mut(|m| m.set_focus_lock_filter(id, EventFilter { vertical_arrows: true, ..Default::default() }));
+ let next = ui.input_mut(|input| {
+ if input.consume_key(Modifiers::NONE, Key::ArrowDown) {
+ Some(index.checked_add(1))
+ } else if input.consume_key(Modifiers::NONE, Key::ArrowUp) {
+ Some(index.checked_sub(1))
+ } else {
+ None
+ }
+ });
+ if let Some(&(_, next, _)) = next.flatten().and_then(|i| enabled.get(i)) {
+ ui.memory_mut(|m| m.request_focus(next));
+ }
+ }
+ (request, focused)
+ }
+
+ fn paint(&self, ui: &Ui, response: &Response, item: &RailItem
, selected: bool, style: &ChromeStyle) {
+ let rect = response.rect;
+ if !rect.is_positive() {
+ return;
+ }
+ let painter = ui.painter();
+ let hovered = response.hovered() && ui.is_enabled();
+ if selected || hovered {
+ painter.rect_filled(rect.shrink(1.0), 4.0, style.active_background);
+ }
+ if response.has_focus() {
+ painter.rect_stroke(rect.shrink(1.0), 4.0, Stroke::new(1.0, style.accent), StrokeKind::Inside);
+ }
+ let tint = if selected || hovered { style.text } else { style.weak_text };
+ let icon = rect.shrink(ICON_INSET);
+ if icon.is_positive() {
+ egui::Image::new(item.icon.clone()).tint(tint).paint_at(ui, icon);
+ }
+ if item.marker && !selected {
+ let corner = if self.rtl { rect.left_top() + vec2(6.0, 6.0) } else { rect.right_top() + vec2(-6.0, 6.0) };
+ painter.circle_filled(corner, MARKER_RADIUS, style.accent);
+ }
+ }
+}
diff --git a/crates/ui/src/panes/rail_tests.rs b/crates/ui/src/panes/rail_tests.rs
new file mode 100644
index 00000000..f599af6f
--- /dev/null
+++ b/crates/ui/src/panes/rail_tests.rs
@@ -0,0 +1,384 @@
+//! Behavior tests for the icon rail, driven by egui pointer and key events. The harness's root ui
+//! has an 8 pt margin, so in an 800 pt wide window the rail and panes span x 8..792.
+
+use egui::accesskit::{Role, Toggled};
+use egui::{Event, ImageSource, Key, Modifiers, PointerButton, Pos2, Rect, Ui, vec2};
+use egui_kittest::{Harness, kittest::NodeT, kittest::Queryable};
+
+use super::*;
+
+const L: f32 = 8.0;
+const R: f32 = 792.0;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+enum Tool {
+ Comments,
+ Bookmarks,
+ Pages,
+ Layers,
+ Files,
+ Tags,
+}
+
+const TOOLS: [Tool; 6] = [Tool::Comments, Tool::Bookmarks, Tool::Pages, Tool::Layers, Tool::Files, Tool::Tags];
+
+fn items() -> Vec> {
+ TOOLS.iter().map(|t| RailItem::new(*t, ImageSource::Uri(format!("rail-test://{t:?}").into()), format!("{t:?}"))).collect()
+}
+
+/// The one-slot task pane the rail opens, at the rail's edge.
+struct Content {
+ edge: Edge,
+}
+
+impl PaneContent for Content {
+ fn title(&self, pane: &Tool) -> String {
+ format!("{pane:?} pane")
+ }
+ fn body(&mut self, ui: &mut Ui, pane: &Tool) {
+ ui.label(format!("{pane:?} body"));
+ }
+ fn default_state(&self, _: &Tool) -> PaneState {
+ PaneState::docked(self.edge, 250.0)
+ }
+ fn movable(&self, _: &Tool) -> bool {
+ false
+ }
+}
+
+/// The app side: which pane is open, and every request the rail sent.
+struct App {
+ items: Vec>,
+ open: Option,
+ log: Vec>,
+ edge: Edge,
+ rtl: bool,
+ /// Show the open pane with a `PaneHost` after the rail.
+ pane: bool,
+ foot: bool,
+ out: Option>,
+ /// `PaneOutput::focused` of the pane host.
+ pane_focused: Option,
+ document: Rect,
+}
+
+impl App {
+ fn new() -> Self {
+ Self {
+ items: items(),
+ open: None,
+ log: Vec::new(),
+ edge: Edge::End,
+ rtl: false,
+ pane: false,
+ foot: false,
+ out: None,
+ pane_focused: None,
+ document: Rect::NOTHING,
+ }
+ }
+
+ fn frame(&mut self, ui: &mut Ui) {
+ let foot = self.foot;
+ let out = Rail::new("rail").edge(self.edge).rtl(self.rtl).show(ui, &self.items, self.open.as_ref(), |ui| {
+ if foot {
+ let _ = ui.button("Foot");
+ }
+ });
+ if self.pane {
+ let open: Vec = self.open.into_iter().collect();
+ let panes = PaneHost::new("panes").rtl(self.rtl).policy(SidePolicy::OneSlot).show(ui, &open, |_| None, &mut Content { edge: self.edge });
+ self.pane_focused = panes.focused;
+ }
+ self.document = egui::CentralPanel::default().show(ui, |ui| ui.label("Document")).response.rect;
+ match &out.request {
+ Some(RailRequest::Open(p)) => self.open = Some(*p),
+ Some(RailRequest::Close(_)) => self.open = None,
+ None => {}
+ }
+ self.log.extend(out.request.clone());
+ self.out = Some(out);
+ }
+
+ fn out(&self) -> &RailOutput {
+ self.out.as_ref().unwrap()
+ }
+}
+
+fn harness(app: App, height: f32) -> Harness<'static, App> {
+ let mut h = Harness::builder().with_size(vec2(800.0, height)).build_ui_state(|ui, app: &mut App| app.frame(ui), app);
+ h.input_mut().focused = true;
+ h.run_steps(2);
+ h
+}
+
+fn item(h: &Harness<'_, App>, tool: Tool) -> Rect {
+ h.ctx.read_response(Rail::new("rail").item_id(&tool)).unwrap().rect
+}
+
+fn click(h: &mut Harness<'_, App>, at: Pos2) {
+ let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
+ h.input_mut().events.extend([Event::PointerMoved(at), button(true)]);
+ h.run_steps(1);
+ h.input_mut().events.push(button(false));
+ h.run_steps(2);
+}
+
+fn click_item(h: &mut Harness<'_, App>, tool: Tool) {
+ let at = item(h, tool).center();
+ click(h, at);
+}
+
+fn key(h: &mut Harness<'_, App>, key: Key) {
+ h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
+ h.run_steps(1);
+ h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
+ h.run_steps(2);
+}
+
+fn focused(h: &Harness<'_, App>) -> Option {
+ h.state().out().focused
+}
+
+#[test]
+fn a_click_opens_the_open_item_closes_and_another_swaps() {
+ let mut h = harness(App::new(), 600.0);
+ click_item(&mut h, Tool::Comments);
+ assert_eq!(h.state().log, [RailRequest::Open(Tool::Comments)]);
+ assert_eq!(h.state().open, Some(Tool::Comments));
+ click_item(&mut h, Tool::Comments);
+ assert_eq!(h.state().log[1..], [RailRequest::Close(Tool::Comments)]);
+ assert_eq!(h.state().open, None);
+ click_item(&mut h, Tool::Comments);
+ click_item(&mut h, Tool::Pages);
+ assert_eq!(h.state().log[2..], [RailRequest::Open(Tool::Comments), RailRequest::Open(Tool::Pages)]);
+ assert_eq!(h.state().open, Some(Tool::Pages));
+}
+
+#[test]
+fn a_disabled_item_sends_nothing_and_the_marker_shows_only_while_closed() {
+ let mut app = App::new();
+ app.items[1].enabled = false;
+ app.items[0].marker = true;
+ app.items[0].marker_label = Some("Has comments".into());
+ app.items[3] = app.items[3].clone().marker(true).marker_label("Has layers");
+ app.open = Some(Tool::Comments);
+ let mut h = harness(app, 600.0);
+ click_item(&mut h, Tool::Bookmarks);
+ assert!(h.state().log.is_empty());
+ assert_eq!(h.state().open, Some(Tool::Comments));
+
+ let markers = |h: &Harness<'_, App>| -> Vec {
+ h.output()
+ .shapes
+ .iter()
+ .filter_map(|s| match &s.shape {
+ egui::Shape::Circle(circle) if circle.radius == 3.0 => Some(circle.center),
+ _ => None,
+ })
+ .collect()
+ };
+ let shown = markers(&h);
+ assert_eq!(shown.len(), 1, "{shown:?}");
+ assert!(item(&h, Tool::Layers).contains(shown[0]));
+ assert!(shown[0].x > item(&h, Tool::Layers).center().x, "the marker sits at the top-right corner");
+ let description = |h: &Harness<'_, App>, label: &str| h.get_by_label(label).accesskit_node().description();
+ assert_eq!(description(&h, "Layers").as_deref(), Some("Has layers"));
+ assert_eq!(description(&h, "Comments"), None, "no description while the pane is open");
+
+ // Closed, Comments shows its marker too.
+ click_item(&mut h, Tool::Comments);
+ assert_eq!(h.state().open, None);
+ let shown = markers(&h);
+ assert_eq!(shown.len(), 2, "{shown:?}");
+ assert!(shown.iter().any(|p| item(&h, Tool::Comments).contains(*p)));
+ h.run_steps(1);
+ // Hovering Comments shows a tooltip with its label; move away before reading by label.
+ h.input_mut().events.push(Event::PointerMoved(Pos2::new(400.0, 300.0)));
+ h.run_steps(2);
+ assert_eq!(description(&h, "Comments").as_deref(), Some("Has comments"));
+
+ let mut app = App::new();
+ app.rtl = true;
+ app.items[3].marker = true;
+ let h = harness(app, 600.0);
+ let shown = markers(&h);
+ assert_eq!(shown.len(), 1, "{shown:?}");
+ assert!(shown[0].x < item(&h, Tool::Layers).center().x, "right-to-left: the marker sits at the top-left corner");
+}
+
+#[test]
+fn the_rail_is_outermost_and_the_open_pane_sits_between_it_and_the_document() {
+ for (edge, rtl, left) in [(Edge::End, false, false), (Edge::End, true, true), (Edge::Start, false, true), (Edge::Start, true, false)] {
+ let mut app = App::new();
+ app.edge = edge;
+ app.rtl = rtl;
+ app.pane = true;
+ app.open = Some(Tool::Comments);
+ let h = harness(app, 600.0);
+ let rail = h.state().out().rect;
+ let pane_id = Id::new("panes").with(("pane", &Tool::Comments));
+ // The resize handle lies on the pane's inner edge; the pane is 250 wide.
+ let inner = h.ctx.read_response(pane_id.with("resize")).unwrap().rect.center().x;
+ let pane = if left {
+ Rect::from_x_y_ranges(inner - 250.0..=inner, rail.y_range())
+ } else {
+ Rect::from_x_y_ranges(inner..=inner + 250.0, rail.y_range())
+ };
+ let title = h.ctx.read_response(pane_id.with("title")).unwrap().rect;
+ let document = h.state().document;
+ let case = format!("{edge:?} rtl {rtl}: rail {rail:?}, pane {pane:?}, title {title:?}, document {document:?}");
+ assert_eq!(rail.width(), RAIL_WIDTH, "{case}");
+ assert!(pane.x_range().contains(title.left()) && pane.x_range().contains(title.right()), "{case}");
+ if left {
+ assert_eq!(rail.left(), L, "{case}");
+ assert_eq!(pane.left(), rail.right(), "{case}");
+ assert!(document.left() >= pane.right(), "{case}");
+ } else {
+ assert_eq!(rail.right(), R, "{case}");
+ assert_eq!(pane.right(), rail.left(), "{case}");
+ assert!(document.right() <= pane.left(), "{case}");
+ }
+ assert!(rail.x_range().contains(item(&h, Tool::Comments).center().x), "{case}");
+ }
+}
+
+#[test]
+fn in_a_short_window_the_items_scroll_and_the_foot_stays_visible() {
+ let mut app = App::new();
+ app.foot = true;
+ let mut h = harness(app, 200.0);
+ let foot = h.get_by_label("Foot").rect();
+ assert!(foot.bottom() <= 192.0, "{foot:?}");
+ assert!(item(&h, Tool::Comments).top() < 30.0);
+ assert!(item(&h, Tool::Tags).bottom() > foot.top(), "the last item starts out of view");
+
+ let over = item(&h, Tool::Comments).center();
+ h.input_mut().events.extend([
+ Event::PointerMoved(over),
+ Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -500.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE },
+ ]);
+ h.run_steps(3);
+ let tags = item(&h, Tool::Tags);
+ assert!(tags.bottom() <= h.get_by_label("Foot").rect().top(), "{tags:?} above the foot after scrolling");
+ assert_eq!(h.get_by_label("Foot").rect(), foot);
+}
+
+#[test]
+fn items_are_named_toggle_buttons_and_the_keyboard_moves_between_and_activates_them() {
+ let mut app = App::new();
+ app.items[1].enabled = false;
+ app.open = Some(Tool::Comments);
+ let mut h = harness(app, 600.0);
+ let comments = h.get_by_label("Comments").accesskit_node();
+ assert_eq!(comments.role(), Role::Button);
+ assert_eq!(comments.toggled(), Some(Toggled::True));
+ assert!(!comments.is_disabled());
+ let pages = h.get_by_label("Pages").accesskit_node();
+ assert_eq!(pages.role(), Role::Button);
+ assert_eq!(pages.toggled(), Some(Toggled::False));
+ assert!(h.get_by_label("Bookmarks").accesskit_node().is_disabled());
+
+ h.ctx.memory_mut(|m| m.request_focus(Rail::new("rail").item_id(&Tool::Comments)));
+ h.run_steps(2);
+ assert_eq!(focused(&h), Some(Tool::Comments));
+ key(&mut h, Key::ArrowUp);
+ assert_eq!(focused(&h), Some(Tool::Comments), "Up on the first item stays");
+ key(&mut h, Key::ArrowDown);
+ assert_eq!(focused(&h), Some(Tool::Pages), "Down skips the disabled item");
+ key(&mut h, Key::Enter);
+ assert_eq!(h.state().log, [RailRequest::Open(Tool::Pages)]);
+ key(&mut h, Key::Space);
+ assert_eq!(h.state().log[1..], [RailRequest::Close(Tool::Pages)]);
+ key(&mut h, Key::ArrowUp);
+ assert_eq!(focused(&h), Some(Tool::Comments));
+ for _ in 0..TOOLS.len() {
+ key(&mut h, Key::ArrowDown);
+ }
+ assert_eq!(focused(&h), Some(Tool::Tags), "Down on the last item stays");
+}
+
+#[test]
+fn an_empty_rail_draws_and_bad_sizes_fall_back() {
+ let mut app = App::new();
+ app.items.clear();
+ let h = harness(app, 600.0);
+ let rail = h.state().out().rect;
+ assert_eq!((rail.right(), rail.width()), (R, RAIL_WIDTH));
+ assert_eq!(h.state().out().request, None);
+
+ let mut h = Harness::builder().with_size(vec2(400.0, 300.0)).build_ui(|ui| {
+ let out = Rail::new("bad").width(f32::NAN).button_size(f32::INFINITY).show(ui, &items(), None, |_| {});
+ assert_eq!(out.rect.width(), RAIL_WIDTH);
+ });
+ h.run_steps(2);
+}
+
+#[test]
+fn a_disabled_item_takes_no_tab_focus_and_a_focused_item_disabled_hands_focus_on() {
+ let mut app = App::new();
+ app.items[1].enabled = false;
+ let mut h = harness(app, 600.0);
+ let focused_id = |h: &Harness<'_, App>| h.ctx.memory(|m| m.focused());
+ let id = |tool: Tool| Rail::new("rail").item_id(&tool);
+ h.ctx.memory_mut(|m| m.request_focus(id(Tool::Comments)));
+ h.run_steps(2);
+ key(&mut h, Key::Tab);
+ assert_eq!(focused_id(&h), Some(id(Tool::Pages)), "Tab skips the disabled item");
+
+ // Pages is disabled while focused: focus goes to the next enabled item.
+ h.state_mut().items[2].enabled = false;
+ h.run_steps(2);
+ assert_eq!(focused_id(&h), Some(id(Tool::Layers)));
+ assert_eq!(focused(&h), Some(Tool::Layers));
+
+ // The last item disabled while focused: focus goes to the enabled one above.
+ h.ctx.memory_mut(|m| m.request_focus(id(Tool::Tags)));
+ h.run_steps(2);
+ h.state_mut().items[5].enabled = false;
+ h.run_steps(2);
+ assert_eq!(focused_id(&h), Some(id(Tool::Files)));
+
+ // Nothing left enabled: focus leaves the rail.
+ for item in &mut h.state_mut().items {
+ item.enabled = false;
+ }
+ h.run_steps(2);
+ assert_eq!((focused_id(&h), focused(&h)), (None, None));
+}
+
+#[test]
+fn a_press_on_the_rail_clears_the_pane_hosts_focused_pane() {
+ let mut app = App::new();
+ app.pane = true;
+ app.open = Some(Tool::Comments);
+ let mut h = harness(app, 600.0);
+ let body = h.get_by_label("Comments body").rect().center();
+ click(&mut h, body);
+ assert_eq!(h.state().pane_focused, Some(Tool::Comments));
+ // Below the items: the rail, not a pane.
+ let rail = h.state().out().rect;
+ click(&mut h, Pos2::new(rail.center().x, rail.bottom() - 40.0));
+ assert_eq!(h.state().pane_focused, None);
+ assert!(h.state().log.is_empty());
+ assert_eq!(h.state().open, Some(Tool::Comments));
+}
+
+#[test]
+fn a_bottom_edge_draws_the_rail_at_the_end() {
+ let mut app = App::new();
+ app.edge = Edge::Bottom;
+ let h = harness(app, 600.0);
+ let rail = h.state().out().rect;
+ assert_eq!((rail.right(), rail.height()), (R, 584.0), "{rail:?}");
+}
+
+#[test]
+fn chrome_style_from_visuals_matches_the_dock_style_it_skips() {
+ use crate::docking::{DockStyle, chrome::ChromeStyle};
+ for visuals in [egui::Visuals::light(), egui::Visuals::dark()] {
+ let direct = ChromeStyle::from_visuals(&visuals);
+ let through_dock = ChromeStyle::from_dock(&DockStyle::from_visuals(&visuals));
+ assert_eq!(format!("{direct:?}"), format!("{through_dock:?}"));
+ }
+}
diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs
index bb433521..9809adcb 100644
--- a/crates/ui/src/panes/tests.rs
+++ b/crates/ui/src/panes/tests.rs
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2, Rect, Ui, pos2, vec2};
-use egui_kittest::{Harness, kittest::Queryable};
+use egui_kittest::{Harness, kittest::NodeT, kittest::Queryable};
use super::*;
@@ -41,6 +41,30 @@ impl PaneContent for Content {
}
}
+/// [`Content`] with the title bar on or off.
+struct Titled(bool);
+
+impl PaneContent for Titled {
+ fn title(&self, pane: &Pane) -> String {
+ Content.title(pane)
+ }
+ fn body(&mut self, ui: &mut Ui, pane: &Pane) {
+ Content.body(ui, pane);
+ }
+ fn default_state(&self, pane: &Pane) -> PaneState {
+ Content.default_state(pane)
+ }
+ fn movable(&self, pane: &Pane) -> bool {
+ Content.movable(pane)
+ }
+ fn closable(&self, pane: &Pane) -> bool {
+ Content.closable(pane)
+ }
+ fn title_bar(&self, _: &Pane) -> bool {
+ self.0
+ }
+}
+
/// The app side: open list, persisted states and every request it received.
struct App {
open: Vec,
@@ -50,17 +74,45 @@ struct App {
to_document: usize,
rtl: bool,
policy: SidePolicy,
+ title_bar: bool,
+ /// The document is one drag-sensing widget, like a canvas.
+ drag_document: bool,
+ document_drags: usize,
}
impl App {
fn new(open: &[Pane]) -> Self {
- Self { open: open.to_vec(), states: HashMap::new(), log: Vec::new(), focused: None, to_document: 0, rtl: false, policy: SidePolicy::Columns }
+ Self {
+ open: open.to_vec(),
+ states: HashMap::new(),
+ log: Vec::new(),
+ focused: None,
+ to_document: 0,
+ rtl: false,
+ policy: SidePolicy::Columns,
+ title_bar: true,
+ drag_document: false,
+ document_drags: 0,
+ }
}
fn frame(&mut self, ui: &mut Ui) {
let states = self.states.clone();
- let out = PaneHost::new("panes").rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Content);
- egui::CentralPanel::default().show(ui, |ui| ui.label("Document"));
+ let out =
+ PaneHost::new("panes").rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Titled(self.title_bar));
+ let drag_document = self.drag_document;
+ // A canvas fills the panel, without the default frame's margin.
+ let panel = if drag_document { egui::CentralPanel::default().frame(Frame::NONE) } else { egui::CentralPanel::default() };
+ let dragged = panel
+ .show(ui, |ui| {
+ if drag_document {
+ ui.interact(ui.max_rect(), Id::new("document-drag"), Sense::click_and_drag()).dragged()
+ } else {
+ ui.label("Document").dragged()
+ }
+ })
+ .inner;
+ self.document_drags += usize::from(dragged);
for request in &out.requests {
match request {
PaneRequest::Close(p) => self.open.retain(|o| o != p),
@@ -150,6 +202,79 @@ fn last_resize(app: &App, pane: Pane) -> Option {
})
}
+#[test]
+fn a_docked_pane_without_a_title_bar_fills_the_pane_and_still_resizes() {
+ for title_bar in [true, false] {
+ let mut app = App::new(&[Pane::Nav]);
+ app.title_bar = title_bar;
+ let mut h = harness(app);
+ let chrome = [
+ rect(&h, pane_id(Pane::Nav).with("title")).is_some(),
+ h.query_by_label("Move Nav pane").is_some(),
+ h.query_by_label("Pane options").is_some(),
+ h.query_by_label("Close").is_some(),
+ ];
+ assert_eq!(chrome, [title_bar; 4], "title bar {title_bar}: title, its node, ▾ and ×");
+ // The body's first line sits under the body margin, below the title bar when there is one.
+ let body = h.get_by_label("Nav body").rect();
+ let top = if title_bar { L + TITLE_BAR_HEIGHT + 8.0 } else { L + 8.0 };
+ assert!((body.top() - top).abs() < 2.0, "title bar {title_bar}: body {body:?}");
+
+ // Without the bar, the body is the pane's accessible region, named by its title.
+ let regions = h.query_all_by_label("Nav").filter(|n| n.accesskit_node().role() == egui::accesskit::Role::Pane).count();
+ assert_eq!(regions, usize::from(!title_bar), "title bar {title_bar}");
+ assert_eq!(h.get_by_label("Resize pane").accesskit_node().role(), egui::accesskit::Role::Splitter);
+
+ drag(&mut h, pos2(L + 250.0, 300.0), pos2(L + 300.0, 300.0));
+ assert_eq!(last_resize(h.state(), Pane::Nav), Some(300.0), "title bar {title_bar}");
+ assert_eq!(edge(&h, Pane::Nav), L + 300.0);
+ }
+}
+
+#[test]
+fn f6_reaches_a_pane_without_a_title_bar() {
+ let mut app = App::new(&[Pane::Nav, Pane::Comments]);
+ app.title_bar = false;
+ let mut h = harness(app);
+ key(&mut h, Key::F6, Modifiers::NONE);
+ assert_eq!(h.state().focused, Some(Pane::Nav));
+ assert_eq!(h.ctx.memory(|m| m.focused()), Some(pane_id(Pane::Nav).with("body")), "F6 focuses the body");
+ key(&mut h, Key::F6, Modifiers::NONE);
+ assert_eq!(h.state().focused, Some(Pane::Comments));
+ key(&mut h, Key::F6, Modifiers::NONE);
+ assert_eq!((h.state().focused, h.state().to_document), (None, 1));
+}
+
+#[test]
+fn the_resize_edge_works_on_both_sides_of_the_line_over_a_dragging_document() {
+ for offset in [-3.5, 3.5] {
+ let mut app = App::new(&[Pane::Nav]);
+ app.drag_document = true;
+ let mut h = harness(app);
+ // The line is at L + 250; positive offsets lie over the document.
+ drag(&mut h, pos2(L + 250.0 + offset, 300.0), pos2(L + 300.0 + offset, 300.0));
+ // The edge follows the pointer.
+ let size = last_resize(h.state(), Pane::Nav);
+ assert!(size.is_some_and(|size| (size - (300.0 + offset)).abs() <= 1.0), "offset {offset}: {size:?}");
+ assert_eq!(h.state().document_drags, 0, "offset {offset}: the document took the drag");
+ }
+ // A floating pane over the edge keeps its presses: the edge stays under floating windows.
+ let mut app = App::new(&[Pane::Nav, Pane::Styles]);
+ app.states.insert(Pane::Styles, PaneState { place: Place::Floating, size: None, float_rect: Some([L + 150.0, 100.0, 220.0, 300.0]) });
+ let mut h = harness(app);
+ h.run_steps(2);
+ drag(&mut h, pos2(L + 250.0, 300.0), pos2(L + 300.0, 300.0));
+ assert_eq!(last_resize(h.state(), Pane::Nav), None, "the edge under a floating pane took the drag");
+
+ // Away from the edge, the document still drags.
+ let mut app = App::new(&[Pane::Nav]);
+ app.drag_document = true;
+ let mut h = harness(app);
+ drag(&mut h, pos2(L + 400.0, 300.0), pos2(L + 450.0, 300.0));
+ assert!(h.state().document_drags > 0);
+ assert_eq!(last_resize(h.state(), Pane::Nav), None);
+}
+
#[test]
fn columns_sit_side_by_side_resize_and_move_to_end() {
let mut h = harness(App::new(&[Pane::Nav, Pane::Styles]));
diff --git a/docs/ui-panes.md b/docs/ui-panes.md
index b9c859ab..201d2af1 100644
--- a/docs/ui-panes.md
+++ b/docs/ui-panes.md
@@ -13,8 +13,8 @@ document stays the app's `CentralPanel`.
| plain egui panels | Fixed view parts: slide thumbnails, notes, formula bar, sheet tabs, status bar. |
| `egui::Window` | Free tool windows that never dock (Read Aloud, Power Query). |
-`panes` needs no docking types; it uses only the docking chrome (`FloatingFrame`, `TitleBar`,
-`live_move`).
+`panes` needs no docking types; it uses only the docking chrome (`ChromeStyle`,
+`FloatingFrame`, `TitleBar`, `live_move`).
## API
@@ -25,7 +25,8 @@ impl PaneContent for MyPanes<'_> {
fn title(&self, pane: &Pane) -> String { tr(pane.title()) }
fn body(&mut self, ui: &mut egui::Ui, pane: &Pane) { /* the pane's contents */ }
fn default_state(&self, pane: &Pane) -> PaneState { PaneState::docked(Edge::End, 250.0) }
- // Optional: size_range (200..=520), movable, closable, menu (app items in the ▾ menu).
+ // Optional: size_range (200..=520), movable, closable, menu (app items in the ▾ menu),
+ // title_bar (false: a docked pane draws no title bar; floating panes always draw one).
}
// Every frame, after the top and bottom bars and before the CentralPanel:
@@ -57,7 +58,7 @@ egui::CentralPanel::default().show(ui, |ui| {
| `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. |
| `SidePolicy::OneSlot` | One closable pane per edge: the last one in `open`. Non-closable context panes (PivotTable Fields) show beside the slot. |
| `.max_fraction(f)` | One docked pane takes at most `f` of the host's width (height for bottom panes). |
-| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). |
+| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The edge takes presses on both sides of its line, over the document's own widgets too, and stays under floating windows. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). |
| Size | A docked pane reserves exactly its size and clips its body, so a body wider than the pane never pushes into the document. |
| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. |
| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it. Rects are clamped to the host area. |
@@ -73,6 +74,65 @@ State:
Escape sends the start rect back.
- `panes::float_rect` brings a saved rect back inside the window, whatever the file says.
+## Rail
+
+`panes::Rail` is a vertical strip of icons, always shown at one edge. Each icon stands for a
+task pane, and one pane at a time is open beside the strip. A click on an icon opens its pane in
+place of the open one; a click on the open pane's icon closes it. The rail stores nothing: the
+app owns which pane is open, as with `PaneHost`.
+
+```rust
+use craft_ui::panes::{Edge, PaneHost, Rail, RailItem, RailRequest, SidePolicy};
+
+let items = [
+ RailItem::new(Pane::Comments, icons.source("comments"), tr("Comments")).marker(has_comments),
+ RailItem::new(Pane::Bookmarks, icons.source("bookmark"), tr("Bookmarks")).enabled(has_outline),
+];
+// After the top and bottom bars, before PaneHost: the rail is the outermost panel on its edge.
+let rail = Rail::new("rail").edge(Edge::End).rtl(ui_is_rtl).show(ui, &items, app.pane.as_ref(), |ui| {
+ page_navigation(ui); // the foot: below the items, always visible; may draw nothing
+});
+match rail.request {
+ Some(RailRequest::Open(pane)) => app.pane = Some(pane),
+ Some(RailRequest::Close(_)) => app.pane = None,
+ _ => {}
+}
+// The open pane, between the document and the rail. Its PaneContent returns `movable = false`
+// and a `default_state` docked at the rail's edge.
+// Apps whose panes draw their own headers return false from `title_bar`.
+let open: Vec = app.pane.into_iter().collect();
+let out = PaneHost::new("task-panes").rtl(ui_is_rtl).policy(SidePolicy::OneSlot).show(ui, &open, |_| None, &mut MyPanes { app });
+egui::CentralPanel::default().show(ui, |ui| document(ui));
+```
+
+| Piece | Behavior |
+|---|---|
+| `RailItem` | `pane`, `icon` (an `egui::ImageSource`, like docking's `DockContent::icon`), `label` (tooltip and accessible name; translate it in the app), `enabled`, `marker`, `marker_label` (the accessible description while the marker shows, such as "Has comments"). `RailItem::new` makes an enabled item without a marker. |
+| `.edge(e)` | `End` (default) or `Start`. `Start` is left, or right with `.rtl(true)`. The rail is vertical, so `Bottom` draws at `End`. |
+| `.width(w)`, `.button_size(s)` | Rail width (48) and item size (34; the icon is 16 smaller). Non-finite values fall back to the defaults. |
+| `.frame(f)` | Default: the panel fill, 8 pt above and below. The panel's separator line draws the inner edge. Item colors come from `ChromeStyle`, like the pane title bars. |
+| Panel | Exact width, not resizable. Shown before `PaneHost::show`, it is outermost, so a docked pane on the same edge sits between it and the document. |
+| Items | Top-down in a vertical scroll area. The foot is a bottom sub-panel, so in a short window the items scroll and never run into it. A pane listed twice is drawn once. An empty list draws an empty rail. |
+| States | The open pane's item draws selected. `marker` draws a dot while the pane is not open. A disabled item draws disabled, takes no focus and sends nothing. An item disabled while focused hands focus to the nearest enabled item, the one below first. |
+| `RailOutput` | `#[non_exhaustive]`. `request`: `Open(p)` or `Close(p)` (`#[non_exhaustive]`), at most one per frame. `focused`: the item holding keyboard focus. `rect`: where the rail was drawn. |
+| Accessibility | Each item is a toggle button (accesskit `Button` with a toggled state) named by its `label`. |
+| Keyboard | Items take Tab focus. Enter or Space clicks the focused item. Up and Down move focus between enabled items and stop at the ends. Escape is the app's: the rail neither closes the pane nor moves focus on it. |
+
+F6 stays with the app. To include the rail in an F6 cycle, give `PaneHost` `.f6(false)` and,
+when the cycle reaches the rail, focus the open pane's item if it is enabled, else the first
+enabled one:
+
+```rust
+let enabled = || items.iter().filter(|item| item.enabled);
+let target = enabled().find(|item| Some(&item.pane) == app.pane.as_ref()).or_else(|| enabled().next());
+if let Some(item) = target {
+ ctx.memory_mut(|m| m.request_focus(Rail::new("rail").item_id(&item.pane)));
+}
+```
+
+While `rail.focused` is `Some`, focus is in the rail: the next F6 moves on, for example to the
+panes with `panes::focus_next`.
+
## Adoption
### WordCraft (`Columns`)