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()
}