nightshade_api/web/workspace.rs
1//! A tabbed dock whose tabs can be dragged between panes using the pointer-drag system.
2
3use leptos::prelude::*;
4
5use crate::web::pointer_drag::{DragPayload, DragSource, DropZone};
6
7/// A tab in a `TabDock`: a stable `id`, a display `title`, and the id of the
8/// `pane` it currently lives in.
9#[derive(Clone)]
10pub struct DockTab {
11 /// Stable identifier used for selection and drag payloads.
12 pub id: String,
13 /// Text shown on the tab button.
14 pub title: String,
15 /// Id of the pane the tab currently belongs to.
16 pub pane: String,
17}
18
19impl DockTab {
20 /// Creates a tab with the given id, title, and owning pane.
21 pub fn new(id: impl Into<String>, title: impl Into<String>, pane: impl Into<String>) -> Self {
22 Self {
23 id: id.into(),
24 title: title.into(),
25 pane: pane.into(),
26 }
27 }
28}
29
30/// Renders one drop-zone pane per entry in `panes`, each showing its `tabs` as
31/// draggable buttons and the body of the `active` tab via the `render` closure.
32/// Dragging a tab onto another pane moves it there and activates it; empty panes
33/// show a drop hint.
34#[component]
35pub fn TabDock<F>(
36 tabs: RwSignal<Vec<DockTab>>,
37 panes: Vec<String>,
38 active: RwSignal<String>,
39 render: F,
40) -> impl IntoView
41where
42 F: Fn(String) -> AnyView + 'static,
43{
44 let render = StoredValue::new_local(render);
45 view! {
46 <div class="nightshade-tabdock">
47 {panes
48 .into_iter()
49 .map(|pane| {
50 let pane_key = StoredValue::new(pane.clone());
51 let tabs_in = move || {
52 tabs.get()
53 .into_iter()
54 .filter(|tab| tab.pane == pane_key.get_value())
55 .collect::<Vec<_>>()
56 };
57 let active_in_pane = move || {
58 let current = active.get();
59 let list = tabs_in();
60 list.iter()
61 .find(|tab| tab.id == current)
62 .map(|tab| tab.id.clone())
63 .or_else(|| list.first().map(|tab| tab.id.clone()))
64 };
65 let on_drop = Callback::new(move |payload: DragPayload| {
66 if payload.kind == "dock-tab" {
67 let target = pane_key.get_value();
68 tabs.update(|list| {
69 if let Some(tab) = list.iter_mut().find(|tab| tab.id == payload.id) {
70 tab.pane = target;
71 }
72 });
73 active.set(payload.id);
74 }
75 });
76 view! {
77 <DropZone
78 id=format!("pane-{}", pane_key.get_value())
79 on_drop=on_drop
80 class="nightshade-tabdock-pane"
81 >
82 <div class="nightshade-tabdock-tabs">
83 {move || {
84 tabs_in()
85 .into_iter()
86 .map(|tab| {
87 let id_click = tab.id.clone();
88 let id_active = tab.id.clone();
89 let is_active = move || active.get() == id_active;
90 view! {
91 <DragSource
92 kind="dock-tab"
93 id=tab.id.clone()
94 label=tab.title.clone()
95 class="nightshade-tabdock-tab"
96 >
97 <button
98 class="nightshade-tabdock-tab-btn"
99 class:active=is_active
100 on:click=move |_| active.set(id_click.clone())
101 >
102 {tab.title}
103 </button>
104 </DragSource>
105 }
106 })
107 .collect_view()
108 }}
109 </div>
110 <div class="nightshade-tabdock-body">
111 {move || match active_in_pane() {
112 Some(id) => render.with_value(|render| render(id)),
113 None => {
114 view! {
115 <div class="nightshade-tabdock-empty">"Drop a tab here"</div>
116 }
117 .into_any()
118 }
119 }}
120 </div>
121 </DropZone>
122 }
123 })
124 .collect_view()}
125 </div>
126 }
127}