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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
//! The band across the top of the detail column: what the pane is showing, and
//! the `···` that acts on it.
//!
//! Pulled, not pushed: [`Cydonia::detail`] already builds the body, so it asks
//! the pane rather than having each register itself into a shared slot the way
//! `../desktop` must. Two panes showing at once is this called twice — see
//! `todos/panels.md`.
use crate::model::workspace::Showing;
use crate::view::{
component::menu::Menu,
leaf::Pane,
root::{self, Cydonia},
sidebar::{Renaming, Row},
};
use bezel::{
gpui::{AnyElement, App, Context, FontWeight, SharedString, Window, div, prelude::*, px},
theme::{TextStyle, Theme, Typeset},
ui::icons,
};
/// What a pane puts in the band.
pub(crate) struct Toolbar {
/// What the pane is showing, by the name the sidebar would list it under.
pub title: String,
pub number: Option<u64>,
/// The entry that name belongs to. Every pane has one today; the field is
/// an option because a pane standing in for something not yet made — see
/// [`Cydonia::launch`] — has a band and nothing to act on.
pub entry: Option<Entry>,
}
/// The thing the title names: what to put a menu on, whether that menu offers
/// Archive or Unarchive, and how the thing is renamed.
pub(crate) struct Entry {
pub row: Row,
pub archived: bool,
pub naming: Naming,
}
/// Where an entry is renamed — one place per kind, never two.
pub(crate) enum Naming {
/// In the band, in place. See [`Cydonia::header_renaming`].
Inline(Renaming),
/// In the board's identity panel, by id: a board is named by its name *and*
/// its key, so one place edits both. See [`Cydonia::toggle_info`].
Panel(String),
/// In the page itself: an article's title is the first line of the
/// document, so the pane already holds the field — see
/// [`crate::view::article`]. Nothing for the band to draw or offer.
Page,
}
impl Cydonia {
/// What the pane showing in `pane` puts in the band. Takes the pane rather
/// than reading [`Cydonia::showing`], so a second panel is a second call.
pub(crate) fn toolbar(&self, pane: Pane, cx: &App) -> Option<Toolbar> {
let workspace = self.workspace.read(cx);
let project = workspace.active?;
let open = workspace.projects.get(project)?;
let showing = match pane {
Pane::Chat => Showing::Session(open.active?),
Pane::Board => Showing::Board(open.board?),
Pane::Article => Showing::Article(open.article?),
Pane::Table => Showing::Table(open.table?),
};
self.toolbar_of(project, showing, cx)
}
/// The same, for an entry named outright rather than read off the
/// project's selection — what a pane of a layout puts in its own bar,
/// which is some entry other than the one in front.
pub(crate) fn toolbar_of(&self, project: usize, showing: Showing, cx: &App) -> Option<Toolbar> {
let workspace = self.workspace.read(cx);
let open = workspace.projects.get(project)?;
Some(match showing {
Showing::Session(id) => {
let chat = open.session(id)?;
Toolbar {
title: chat.label(),
number: chat.number,
entry: Some(Entry {
row: Row::Session { project, id },
archived: chat.closed,
naming: Naming::Inline(Renaming::Session(id)),
}),
}
}
Showing::Board(ix) => {
let board = open.boards.get(ix)?;
Toolbar {
title: board.label().to_owned(),
number: board.number,
entry: Some(Entry {
row: Row::Board { project, ix },
archived: board.archived,
naming: Naming::Panel(board.id.clone()),
}),
}
}
Showing::Article(ix) => {
let article = open.articles.get(ix)?;
Toolbar {
title: article.label().to_owned(),
number: article.number,
entry: Some(Entry {
row: Row::Article { project, ix },
archived: article.archived,
naming: Naming::Page,
}),
}
}
Showing::Table(ix) => {
let table = open.tables.get(ix)?;
Toolbar {
title: table.name.clone(),
number: table.number,
entry: Some(Entry {
row: Row::Table { project, ix },
archived: table.archived,
naming: Naming::Inline(Renaming::Table(table.key.clone())),
}),
}
}
})
}
/// Which rename the band is drawing the name field for. One field, one
/// place: the sidebar row asks this before drawing it, and the band wins
/// because it is what is left when the sidebar is folded away.
pub(crate) fn header_renaming(&self, cx: &App) -> Option<&Renaming> {
// A layout draws no band, so there is no field here to be the one
// place — the sidebar row draws it instead. Without this the field
// would be nowhere: the row stands down for a band that is not there.
if self.arranged(cx) {
return None;
}
let at = self.renaming.as_ref()?;
let showing = self.toolbar(self.showing(cx)?, cx)?;
let Naming::Inline(shown) = showing.entry?.naming else {
return None;
};
(shown == *at).then_some(at)
}
/// The band, drawn over the pane rather than above it — the composer sits
/// on the other edge the same way.
pub(crate) fn pane_header(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let toolbar = self.showing(cx).and_then(|pane| self.toolbar(pane, cx));
// The lights belong to the window, not a pane, so their clearance is
// taken here and nowhere a pane can see it.
let inset = match self.sidebar_open || window.is_fullscreen() {
true => root::HEADER_INSET,
false => root::TOOLBAR_INSET,
};
let renaming = self.header_renaming(cx).is_some();
// Which board the band is showing, if any — the only entry with a
// panel.
let board = toolbar.as_ref().and_then(|toolbar| match &toolbar.entry {
Some(Entry {
naming: Naming::Panel(id),
..
}) => Some(id.clone()),
_ => None,
});
div()
.absolute()
.top_0()
.left_0()
.right_0()
.h(px(root::HEADER_HEIGHT))
.flex()
.flex_row()
.items_center()
.gap(px(8.))
.pl(px(inset))
.pr(px(root::HEADER_INSET))
// Above the pane, which runs under it.
// The fold belongs to whichever column runs along the window's
// left edge, so with the sidebar gone it is this one's.
.children(
(!self.sidebar_open).then(|| self.fold_toggle(theme.text, cx).into_any_element()),
)
.children(toolbar.map(|toolbar| {
match renaming {
true => div().flex_1().min_w_0().child(self.name_field(cx)),
false => div()
.flex_1()
.min_w_0()
.flex()
.flex_row()
.items_center()
.gap(px(6.))
// A board's name opens its identity panel; the other
// panes have one field and rename from the `···`.
.child(
div()
.relative()
.min_w_0()
.flex_1()
// A row, so the name is only as wide as it
// reads — stretched, it would swallow presses
// on the empty half of the band.
.flex()
.flex_row()
.items_center()
.child(
div()
.id("header-title")
.min_w_0()
.truncate()
.line_clamp(1)
.text_style(TextStyle::Subheadline)
.font_weight(FontWeight::MEDIUM)
.text_color(theme.text)
.when_some(board.clone(), |title, _| title.cursor_pointer())
.child(toolbar.title)
.when_some(board.clone(), |title, id| {
// Noted on the way down, ahead of
// the panel's own dismiss — see
// [`Cydonia::toggle_info`].
let opened = id.clone();
title
.capture_any_mouse_down(cx.listener(
move |this, _, _, _| {
this.info_pressed =
this.info.as_ref().is_some_and(
|info| info.board == opened,
);
},
))
.on_click(cx.listener(
move |this, _, window, cx| {
this.toggle_info(&id, window, cx);
},
))
}),
)
.children(toolbar.number.map(|number| {
div()
.id("header-entry-number")
.flex_none()
.ml(px(6.))
.text_style(TextStyle::Caption)
.font_weight(FontWeight::NORMAL)
.text_color(theme.text_muted)
.child(format!("#{number}"))
}))
.children(board.as_deref().and_then(|id| self.info_panel(id, cx))),
)
.children(toolbar.entry.map(|entry| {
self.menu_button(
SharedString::from("header-menu"),
// On show: hiding a trigger until the pointer
// finds it is what a list of rows needs, not a
// band with one control.
None,
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Header,
cx,
)
.children(self.entry_menu(
Menu::Header,
entry.row,
entry.archived,
cx,
))
})),
}
}))
.into_any_element()
}
}