ltk/widget/notebook/mod.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Notebook — paginated tabs with a content area.
//!
//! Different from `tab_bar` (which is just a segmented selector — the
//! application is responsible for rendering whatever content
//! corresponds to the active tab). A `Notebook` owns the
//! pages: each page bundles a label *and* its content, and only the
//! active page's content is laid out / drawn each frame.
//!
//! The widget itself is stateless: the application owns
//! `self.tab: usize` and updates it from the `on_select` callback. The
//! pages can be built fresh every frame (typical) or memoised on the
//! application side.
//!
//! ```rust,no_run
//! # use ltk::{ notebook, text, Element, Notebook };
//! # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
//! # struct App { tab: usize }
//! # impl App {
//! # fn general_view( &self ) -> Element<Msg> { text( "g" ).into() }
//! # fn network_view( &self ) -> Element<Msg> { text( "n" ).into() }
//! # fn audio_view( &self ) -> Element<Msg> { text( "a" ).into() }
//! # fn _ex( &self ) -> Notebook<Msg> {
//! notebook()
//! .page( "General", self.general_view() )
//! .page( "Network", self.network_view() )
//! .page( "Audio", self.audio_view() )
//! .selected( self.tab )
//! .on_select( Msg::SelectTab )
//! # }
//! # }
//! ```
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::spacer::spacer;
use super::Element;
mod theme;
#[ cfg( test ) ]
mod tests;
/// One page of a [`Notebook`] — a label for the tab strip and the
/// element to show when this page is active.
pub struct NotebookPage<Msg: Clone>
{
pub label: String,
pub view: Element<Msg>,
}
/// Paginated tab container.
///
/// Renders a `tab_bar` strip at the top followed by the
/// active page's content. Pages whose index is not [`Self::selected`]
/// are dropped before draw so they do not consume layout time — a
/// notebook with 50 pages costs the same to draw as one with 5.
pub struct Notebook<Msg: Clone>
{
pub pages: Vec<NotebookPage<Msg>>,
pub selected: usize,
pub on_select: Option<Arc<dyn Fn( usize ) -> Msg>>,
}
impl<Msg: Clone + 'static> Notebook<Msg>
{
/// Create an empty notebook with no pages and no selection.
pub fn new() -> Self
{
Self
{
pages: Vec::new(),
selected: 0,
on_select: None,
}
}
/// Append a page. Returns `Self` for chaining.
pub fn page(
mut self,
label: impl Into<String>,
view: impl Into<Element<Msg>>,
) -> Self
{
self.pages.push( NotebookPage
{
label: label.into(),
view: view.into(),
} );
self
}
/// Index of the currently active page. Out-of-range values fall
/// back to page 0; a notebook with no pages renders an empty
/// container.
pub fn selected( mut self, idx: usize ) -> Self
{
self.selected = idx;
self
}
/// Callback invoked with the index of the tab the user tapped.
pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select = Some( Arc::new( f ) );
self
}
/// Build the `Element` tree representing this notebook.
pub fn build( self ) -> Element<Msg>
{
use super::tab_bar::tabs;
let labels: Vec<String> = self.pages.iter().map( |p| p.label.clone() ).collect();
let selected = if self.selected < self.pages.len() { self.selected } else { 0 };
let n_pages = self.pages.len();
// Build the tab strip; wire on_select if the caller provided one.
let mut strip = tabs::<Msg, _, _>( labels ).selected( selected );
if let Some( cb ) = self.on_select.clone()
{
strip = strip.on_select( move |i| cb( i ) );
}
// Drain to extract the active page's view by index.
let mut pages = self.pages;
let active_view: Element<Msg> = if pages.is_empty()
{
spacer().into()
} else {
let _ = n_pages;
pages.swap_remove( selected )
.view
};
column()
.spacing( theme::SPACING )
.push( strip )
.push( active_view )
.into()
}
}
impl<Msg: Clone + 'static> Default for Notebook<Msg>
{
fn default() -> Self { Self::new() }
}
impl<Msg: Clone + 'static> From<Notebook<Msg>> for Element<Msg>
{
fn from( n: Notebook<Msg> ) -> Self { n.build() }
}
/// Create an empty [`Notebook`].
///
/// ```rust,no_run
/// # use ltk::{ notebook, text, Element, Notebook };
/// # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
/// # struct App { tab: usize }
/// # impl App {
/// # fn inbox_view( &self ) -> Element<Msg> { text( "i" ).into() }
/// # fn sent_view( &self ) -> Element<Msg> { text( "s" ).into() }
/// # fn _ex( &self ) -> Notebook<Msg> {
/// notebook()
/// .page( "Inbox", self.inbox_view() )
/// .page( "Sent", self.sent_view() )
/// .selected( self.tab )
/// .on_select( Msg::SelectTab )
/// # }
/// # }
/// ```
pub fn notebook<Msg: Clone + 'static>() -> Notebook<Msg>
{
Notebook::new()
}