Skip to main content

qcode/ui/workspace/
panel.rs

1//! The widget panel on the right: which widgets it carries, in what order, and what they show.
2
3use qframe::date::DateTime;
4use qframe::prelude::*;
5use qframe::widgets::{
6    Click, ContextItem, ContextMenu, FileManager, FileManagerState, MenuTarget, Popover, RowMark, Section, ShimmerText,
7    Spinner, Switch, Tooltip, WidgetDock,
8};
9
10use crate::engine::ContainerState;
11
12use super::files;
13use super::{Msg, OpenWorkspace, WorkspaceScreen};
14
15/// A widget the panel can carry.
16///
17/// There are three; a fourth is added here and in the dock's body, not by a trait, because a widget
18/// is a shape on screen rather than a thing with behaviour of its own.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PanelWidget {
21    /// The workspace's own files.
22    Files,
23    /// What the workspace is and where it lives.
24    Info,
25    /// The workspace's containers, with the way to stop and restart them.
26    Containers,
27}
28
29impl PanelWidget {
30    /// Every widget, in the order the chooser offers them.
31    pub const ALL: [Self; 3] = [Self::Files, Self::Info, Self::Containers];
32
33    /// The key the widget's text and icon are found by.
34    #[must_use]
35    pub fn key(self) -> &'static str {
36        match self {
37            Self::Files => "files",
38            Self::Info => "info",
39            Self::Containers => "containers",
40        }
41    }
42
43    /// The icon of the widget's title.
44    #[must_use]
45    pub fn icon(self) -> &'static str {
46        match self {
47            Self::Files => "folder",
48            Self::Info => "info",
49            Self::Containers => "inbox",
50        }
51    }
52
53    /// The widget's title, in the person's language.
54    #[must_use]
55    pub fn title(self) -> String {
56        t!(&format!("workspace.widget.{}", self.key()))
57    }
58}
59
60/// What the panel carries and how it stands. The application owns all of it, so it could be
61/// saved and given back exactly as it was left.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Panel {
64    open: bool,
65    width: u16,
66    order: Vec<PanelWidget>,
67    opened: Vec<PanelWidget>,
68    chooser: bool,
69    /// The row of the chooser the keyboard is on.
70    chooser_row: usize,
71}
72
73impl Default for Panel {
74    fn default() -> Self {
75        Self {
76            open: true,
77            width: super::PANEL_WIDTH,
78            order: PanelWidget::ALL.to_vec(),
79            opened: vec![PanelWidget::Files, PanelWidget::Containers],
80            chooser: false,
81            chooser_row: 0,
82        }
83    }
84}
85
86impl Panel {
87    /// Whether the panel is open.
88    #[must_use]
89    pub fn is_open(&self) -> bool {
90        self.open
91    }
92
93    /// How wide the panel is when open.
94    #[must_use]
95    pub fn width(&self) -> u16 {
96        self.width
97    }
98
99    /// The widgets the panel carries, in the order they are shown.
100    #[must_use]
101    pub fn order(&self) -> &[PanelWidget] {
102        &self.order
103    }
104
105    /// Whether the widget at `index` is unfolded.
106    #[must_use]
107    pub fn is_unfolded(&self, index: usize) -> bool {
108        self.order.get(index).is_some_and(|widget| self.opened.contains(widget))
109    }
110
111    /// Opens or closes the panel.
112    pub fn set_open(&mut self, open: bool) {
113        self.open = open;
114    }
115
116    /// Sets the panel's width; the framework keeps it within the screen's room.
117    pub fn set_width(&mut self, width: u16) {
118        self.width = width;
119    }
120
121    /// Unfolds or folds the widget at `index`.
122    pub fn toggle(&mut self, index: usize, open: bool) {
123        let Some(widget) = self.order.get(index).copied() else { return };
124        self.opened.retain(|shown| *shown != widget);
125        if open {
126            self.opened.push(widget);
127        }
128    }
129
130    /// Moves the widget at `from` so that its position becomes `to`.
131    pub fn move_widget(&mut self, from: usize, to: usize) {
132        if from >= self.order.len() || to >= self.order.len() {
133            return;
134        }
135        let widget = self.order.remove(from);
136        self.order.insert(to, widget);
137    }
138
139    /// Shows or dismisses the chooser of the widgets the panel does not carry; it always opens
140    /// on its first row.
141    pub fn show_chooser(&mut self, show: bool) {
142        self.chooser = show;
143        self.chooser_row = 0;
144    }
145
146    /// Puts the chooser's keyboard on `row`.
147    pub fn highlight(&mut self, row: usize) {
148        self.chooser_row = row;
149    }
150
151    /// Whether the chooser is open.
152    #[must_use]
153    pub fn chooser_open(&self) -> bool {
154        self.chooser
155    }
156
157    /// Adds `widget` to the end of the panel, unfolded, which is where the person is looking when
158    /// they add it, and closes the chooser that offered it. A widget the panel already carries
159    /// stays where it is.
160    pub fn add(&mut self, widget: PanelWidget) {
161        if !self.order.contains(&widget) {
162            self.order.push(widget);
163            self.opened.push(widget);
164        }
165        self.chooser = false;
166    }
167
168    /// Takes `widget` off the panel; it keeps nothing, so adding it again starts it afresh.
169    pub fn remove(&mut self, widget: PanelWidget) {
170        self.order.retain(|shown| *shown != widget);
171        self.opened.retain(|shown| *shown != widget);
172    }
173
174    /// The widgets the panel does not carry, in the order [`PanelWidget::ALL`] lists them.
175    #[must_use]
176    pub fn missing(&self) -> Vec<PanelWidget> {
177        PanelWidget::ALL.into_iter().filter(|widget| !self.carries(*widget)).collect()
178    }
179
180    /// Whether the panel carries `widget`.
181    #[must_use]
182    pub fn carries(&self, widget: PanelWidget) -> bool {
183        self.order.contains(&widget)
184    }
185}
186
187/// The name the list of widgets to add is focused by.
188pub(super) const CHOOSER_ID: &str = "workspace-panel-chooser";
189
190/// Draws the panel: its title bar, the chooser of widgets and the dock itself.
191pub(super) fn view(screen: &WorkspaceScreen, ui: &mut View<'_, Msg>) {
192    let panel = screen.panel();
193    ui.column(|ui| {
194        ui.row(|ui| {
195            ui.add(Text::new(t!("workspace.panel.title")).role("title").no_wrap());
196            ui.spacer();
197            chooser(panel, ui);
198        })
199        .fill_width();
200        dock(screen, ui);
201        super::backups::view(screen, ui);
202    })
203    .gap(1)
204    .padding(Padding::symmetric(1, 1))
205    .fill();
206}
207
208/// The control that adds a widget the panel does not carry yet: an icon-only `+` whose popover
209/// lists just the missing widgets. With every widget on the panel there is nothing to add, so the
210/// control is not drawn at all rather than opening an empty list. Taking a widget off is the
211/// dock's context menu, see [`dock`].
212fn chooser(panel: &Panel, ui: &mut View<'_, Msg>) {
213    let missing = panel.missing();
214    if missing.is_empty() {
215        return;
216    }
217    let open = panel.chooser_open();
218    Popover::new(open)
219        .on_dismiss(Msg::ShowWidgets(false))
220        .anchor(|ui| {
221            // The glyph is the label rather than the icon, so the button stays symmetric: an icon
222            // is always followed by a gap that only makes sense before a word.
223            let plus = ui.env().icons().glyph("add").into_owned();
224            ui.add_with(Tooltip::new(t!("workspace.panel.add")).on_focus(true), |ui| {
225                ui.add(Button::new(plus).on_press(Msg::ShowWidgets(!open))).id("workspace-panel-add");
226            });
227        })
228        .content(|ui| {
229            let items = missing.iter().map(|widget| ListItem::new(widget.title()).icon(widget.icon(), None));
230            let offered = missing.clone();
231            let row = panel.chooser_row.min(missing.len() - 1);
232            let list = List::new(items)
233                .selected(Some(row))
234                .on_select(Msg::HighlightWidget)
235                .on_activate(move |index| Msg::AddWidget(offered[index]));
236            ui.add(list.wrap(true)).id(CHOOSER_ID).width(Length::Cells(26));
237        })
238        .show(ui);
239}
240
241/// The stack of widgets.
242fn dock(screen: &WorkspaceScreen, ui: &mut View<'_, Msg>) {
243    let panel = screen.panel();
244    let sections = panel.order().iter().map(|widget| Section::new(widget.title()).icon(widget.icon()));
245    let open: Vec<bool> = (0..panel.order().len()).map(|index| panel.is_unfolded(index)).collect();
246    let dock = WidgetDock::new(sections)
247        .open(open)
248        .empty_text(t!("workspace.panel.none"))
249        .on_toggle(Msg::ToggleWidget)
250        .on_move(move |from, to| Msg::MoveWidget { from, to });
251    // A right click anywhere on the dock, or Shift+F10 from a widget in it, offers to take each
252    // carried widget off. The dock is one area for the menu rather than one per widget, so a
253    // folded widget, which shows only its title, can be taken off as well.
254    let removals = panel.order().iter().map(|widget| {
255        ContextItem::new(t!("workspace.panel.remove", widget = widget.title()), Msg::RemoveWidget(*widget))
256            .icon(widget.icon())
257    });
258    ui.add_with(ContextMenu::new(removals), |ui| {
259        ui.add_with(dock, |ui| {
260            for widget in panel.order() {
261                ui.column(|ui| body(screen, *widget, ui)).fill().id(widget.key());
262            }
263        })
264        .fill()
265        .id("workspace-panel-dock");
266    })
267    .fill();
268}
269
270/// The content of one widget.
271fn body(screen: &WorkspaceScreen, widget: PanelWidget, ui: &mut View<'_, Msg>) {
272    let Some(workspace) = screen.workspace() else { return };
273    match widget {
274        PanelWidget::Files => files_widget(workspace, screen.engine().is_some(), ui),
275        PanelWidget::Info => info_widget(screen, workspace, ui),
276        PanelWidget::Containers => containers_widget(screen, workspace, ui),
277    }
278}
279
280/// The workspace's own files, in the framework's file manager; with `engine` their earlier
281/// versions can be looked for too.
282fn files_widget(workspace: &OpenWorkspace, engine: bool, ui: &mut View<'_, Msg>) {
283    let tree = workspace.files();
284    if tree.children(FileManagerState::ROOT).is_none() && tree.error().is_none() {
285        // An unread folder is not an empty one, so it never says "empty" before it is known.
286        ui.add(Spinner::new().label(t!("workspace.files.reading")));
287        return;
288    }
289    if let Some(problem) = tree.error() {
290        // Said here rather than by the manager, so the panel names the folder it is about.
291        ui.add(Text::new(t!("workspace.files.unreadable")).role("secondary"));
292        ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
293        return;
294    }
295    let skip = workspace.backup_skip().to_vec();
296    let marked = skip.clone();
297    let folders = tree.folder_keys();
298    let root = tree.root().to_path_buf();
299    FileManager::new(tree, files::wrap(workspace.id()))
300        .root_label(workspace.name().to_owned())
301        // One click opens, as it always has in qcode: a file in its tab, a folder where it stands.
302        // Ctrl and Shift with a click still only select, and a drag still carries the selection.
303        .open_on(Click::Single)
304        .on_open(move |path| Msg::OpenFile(files::key_of(&root, path)))
305        .menu_for(move |target| own_items(target, &skip, engine))
306        .row_mark(move |key| mark(key, folders.contains(key), &marked))
307        .id(FILES_ID)
308        .show(ui)
309        .fill();
310}
311
312/// The name the file tree is focused by.
313pub(super) const FILES_ID: &str = "workspace-files";
314
315/// The theme colour of the icon of an entry the backup leaves out.
316const LEFT_OUT_TONE: &str = "warning";
317
318/// What qcode says about the look of the row `key`, a folder when `folder`, in a workspace whose
319/// backup leaves `skip` out.
320///
321/// The workspace folder itself carries the workspace's icon, so the top row reads as the workspace
322/// rather than as one more folder. What the backup leaves out is faint, with everything in it, and
323/// its icon takes the warning tone so it is told apart from a cut entry, which is faint in its own
324/// colour; the workspace widget names it in words. A word beside the row would not do: the panel is
325/// narrow, and the tree gives a detail its room before the name.
326fn mark(key: &str, folder: bool, skip: &[String]) -> RowMark {
327    if key == FileManagerState::ROOT {
328        return RowMark::new().plain_sign("workspace");
329    }
330    if super::backups::is_left_out(skip, key) {
331        let icon = if folder { "folder" } else { "file" };
332        return RowMark::new().sign(icon, LEFT_OUT_TONE).faint(true);
333    }
334    RowMark::new()
335}
336
337/// qcode's own items on the menu of a row, in a workspace whose backup leaves `skip` out: a file's
338/// earlier versions, read out of the backup in a container and so only with `engine`, and leaving
339/// entries out of the backup or taking them in again. The manager puts them in a group of their
340/// own before its last, destructive item.
341fn own_items(target: &MenuTarget<'_>, skip: &[String], engine: bool) -> Vec<ContextItem<Msg>> {
342    let backup = backup_item(target.key, target.selection, skip);
343    if target.selection.len() > 1 || target.folder {
344        return backup.into_iter().collect();
345    }
346    let versions = ContextItem::new(t!("workspace.files.versions"), Msg::ShowBackups(Some(target.key.to_owned())))
347        .disabled(!engine);
348    std::iter::once(versions).chain(backup).collect()
349}
350
351/// The item that leaves the entries `targets`, asked for on the row `key`, out of the backup of a
352/// workspace that leaves `skip` out, or takes them in again when every one of them is left out by
353/// name. An entry inside a folder that is left out goes with its folder, so it offers neither;
354/// the workspace folder itself is not among `targets` and offers neither either.
355///
356/// Unlike cutting and deleting, the item does not count what it acts on: it is undone as easily
357/// as it is done, and the menu stays narrow enough to leave the names below it readable.
358fn backup_item(key: &str, targets: &[String], skip: &[String]) -> Option<ContextItem<Msg>> {
359    let message = |out: bool| Msg::LeaveOut(key.to_owned(), out);
360    if !targets.is_empty() && targets.iter().all(|target| skip.contains(target)) {
361        return Some(ContextItem::new(t!("workspace.files.back-up"), message(false)));
362    }
363    if targets.iter().all(|target| super::backups::is_left_out(skip, target)) {
364        return None;
365    }
366    Some(ContextItem::new(t!("workspace.files.leave-out"), message(true)))
367}
368
369/// Cells of the panel a widget's own content never has: the dock's indent on the left, the
370/// panel's padding on the right. What is left is what a row of buttons has to fit in.
371const WIDGET_INSET: u16 = 7;
372
373/// What the workspace is and where it lives.
374fn info_widget(screen: &WorkspaceScreen, workspace: &OpenWorkspace, ui: &mut View<'_, Msg>) {
375    let engine = screen
376        .engine()
377        .map_or_else(|| t!("workspace.info.no-engine"), |engine| format!("{:?}", engine.kind()).to_lowercase());
378    let rows = [
379        (t!("workspace.info.name"), workspace.name().to_owned()),
380        (t!("workspace.info.id"), workspace.id().to_owned()),
381        (t!("workspace.info.folder"), workspace.paths().root.display().to_string()),
382        (
383            t!("workspace.info.profiles"),
384            workspace.profiles().iter().filter(|profile| workspace.carries(profile.name.as_str())).count().to_string(),
385        ),
386        (t!("workspace.info.engine"), engine),
387        (t!("workspace.info.backup"), super::backups::last_text(screen, workspace, DateTime::now_local())),
388        (t!("workspace.info.left-out"), super::backups::left_out_text(workspace)),
389        (t!("workspace.info.backup-size"), super::backups::size_text(workspace)),
390    ];
391    let backing_up = super::backups::is_running(screen, workspace);
392    let backup_label = t!("workspace.info.backup");
393    ui.column(|ui| {
394        for (label, value) in rows {
395            // A backup under way shines in its row, whatever the last one was; it is the panel's
396            // only moving thing, and it stops the moment the round is over.
397            if backing_up && label == backup_label {
398                ui.row(|ui| {
399                    ui.add(Text::new(format!("{label}  ")).role("faint"));
400                    ui.add(ShimmerText::new(t!("workspace.backup.running"))).id("workspace-backing-up");
401                });
402                continue;
403            }
404            ui.add(Text::rich([Span::new(format!("{label}  ")).role("faint"), Span::new(value)]));
405        }
406    })
407    .fill_width()
408    .selectable(true);
409    ui.add(Switch::new(workspace.backs_up_assets()).label(t!("workspace.backup.assets")).on_toggle(Msg::BackupAssets))
410        .id("workspace-backup-assets");
411    // The backups are read in a container, so without an engine there is no list to open.
412    let backups = Button::new(t!("workspace.backup.list")).disabled(screen.engine().is_none());
413    ui.add(backups.on_press(Msg::ShowBackups(None))).id("workspace-backups-open");
414}
415
416/// The workspace's containers, and the way to stop and restart them.
417fn containers_widget(screen: &WorkspaceScreen, workspace: &OpenWorkspace, ui: &mut View<'_, Msg>) {
418    if screen.engine().is_none() {
419        ui.add(Text::new(t!("workspace.no-engine")).role("secondary"));
420        return;
421    }
422    if let Some(failure) = &workspace.container_error {
423        ui.add(Text::new(t!("workspace.containers.unreadable")).role("secondary"));
424        ui.add(Text::new(failure.output.clone()).role("faint")).selectable(true);
425    }
426    // The panel is narrow and every container of a workspace starts with the same `qcode-<id>-`,
427    // so the part that differs is what is shown; the whole name is in the engine's own listing.
428    let prefix = format!("qcode-{}-", workspace.id());
429    if workspace.containers().is_empty() {
430        // A list's empty text is one line cut at the panel's edge, and the panel is narrow enough
431        // that the sentence loses its end; as text of its own it wraps and is read whole.
432        ui.add(Text::new(t!("workspace.containers.empty")).role("secondary")).fill_width().id("workspace-containers");
433    } else {
434        let rows = workspace.containers().iter().map(|container| {
435            let short = container.name.strip_prefix(&prefix).unwrap_or(&container.name);
436            ListItem::new(short.to_owned())
437                .icon("dot", Some(tone(&container.state)))
438                // A container QCode froze is shown as frozen rather than as the engine's `paused`:
439                // the person did not pause it and cannot unpause it, and QCode wakes it by itself
440                // the moment it is needed.
441                .detail(state_text(&container.state, screen.freezing.frozen.contains(&container.name)))
442        });
443        let list = List::new(rows).selected(Some(workspace.container_row)).on_select(Msg::SelectContainer);
444        ui.add(list.wrap(true)).fill().id("workspace-containers");
445    }
446
447    let selected = workspace.containers().get(workspace.container_row);
448    let name = selected.map(|container| container.name.clone());
449    // What the buttons act on is a container that is up, whether or not it is frozen: a tab cannot
450    // be entered while it is frozen, but stopping it, starting it again and asking for a root shell
451    // in it all wake it on the way, which is what those commands do.
452    let up = selected.is_some_and(|container| container.state.is_up());
453    let busy = workspace.busy;
454    let labels =
455        [t!("workspace.containers.stop"), t!("workspace.containers.restart"), t!("workspace.containers.refresh")];
456    let buttons = |ui: &mut View<'_, Msg>| {
457        let stop = name.clone().map(Msg::StopContainer);
458        let mut button = Button::new(labels[0].clone()).disabled(!up || busy);
459        if let Some(message) = stop {
460            button = button.on_press(message);
461        }
462        ui.add(button).id("workspace-container-stop");
463
464        let restart = name.clone().map(Msg::RestartContainer);
465        let mut button = Button::new(labels[1].clone()).disabled(selected.is_none() || busy);
466        if let Some(message) = restart {
467            button = button.on_press(message);
468        }
469        ui.add(button).id("workspace-container-restart");
470
471        // The engine is the only one who knows; the list is what it said last, and this asks again.
472        ui.add(Button::new(labels[2].clone()).loading(busy).disabled(busy).on_press(Msg::RefreshContainers))
473            .id("workspace-container-refresh");
474    };
475    // Three words side by side fit an English panel and not a German or Russian one. Rather than
476    // cut a word, the row becomes a column as soon as the three no longer fit the panel's width.
477    if buttons_width(&labels) <= screen.panel().width().saturating_sub(WIDGET_INSET) {
478        ui.row(buttons).gap(1).fill_width();
479    } else {
480        ui.column(buttons).fill_width();
481    }
482    // Root in a profile's container, for what its system lacks; what it installs stays in this
483    // workspace. Offered for a profile's own container only, and only while it is up.
484    let profile = workspace.profiles.iter().find(|profile| {
485        name.as_deref() == Some(crate::engine::names::profile_container(workspace.id(), profile.name.as_str()).as_str())
486    });
487    if let Some(profile) = profile {
488        let mut admin = Button::new(t!("workspace.admin.open")).disabled(!up);
489        if up {
490            admin = admin.on_press(Msg::OpenAdmin(profile.name.to_string()));
491        }
492        ui.add(admin).id("workspace-container-admin");
493    }
494}
495
496/// Cells a row of these buttons asks for: each label in its own button, which the theme pads by
497/// two cells on either side, and one cell between neighbours.
498fn buttons_width(labels: &[String]) -> u16 {
499    let gaps = u16::try_from(labels.len().saturating_sub(1)).unwrap_or(0);
500    labels.iter().fold(gaps, |total, label| total.saturating_add(qframe::text::width(label)).saturating_add(4))
501}
502
503/// The theme colour of a container's state. Colour never carries the meaning alone: the state is
504/// written out beside the dot.
505fn tone(state: &ContainerState) -> &'static str {
506    match state {
507        ContainerState::Running => "success",
508        ContainerState::Created | ContainerState::Restarting => "info",
509        ContainerState::Paused => "warning",
510        ContainerState::Dead => "danger",
511        ContainerState::Exited | ContainerState::Removing | ContainerState::Unknown(_) => "muted",
512    }
513}
514
515/// A container's state in the person's language; a word this version does not know is shown as
516/// the engine wrote it rather than hidden.
517///
518/// A paused container QCode froze reads as frozen: it is asleep because nothing of it is on screen
519/// and it has done nothing for a while, and the person neither paused it nor can unpause it, since
520/// QCode wakes it by itself the moment one of its tabs is needed. A paused container QCode did not
521/// freeze — one a crash left behind, or one the person paused by hand — reads as paused, which is
522/// what the engine says of it and the only word that tells the person how to undo it.
523fn state_text(state: &ContainerState, frozen: bool) -> String {
524    if frozen && matches!(state, ContainerState::Paused) {
525        return t!("workspace.state.frozen");
526    }
527    let key = match state {
528        ContainerState::Created => "created",
529        ContainerState::Running => "running",
530        ContainerState::Paused => "paused",
531        ContainerState::Restarting => "restarting",
532        ContainerState::Removing => "removing",
533        ContainerState::Exited => "exited",
534        ContainerState::Dead => "dead",
535        ContainerState::Unknown(word) => return word.clone(),
536    };
537    t!(&format!("workspace.state.{key}"))
538}