cydonia 0.1.6

Desktop workspace for the ACP agents you run, keeping what they produce as files on your disk
Documentation
//! The performance section: the switch that puts the frame meter on every
//! window, and what the app is holding while you use it.
//!
//! The switch is here, the meter is not: it lives on the windows themselves,
//! so closing this one leaves the app's own meter where you dragged it. The
//! counts under it are the other half of the same question — a rate belongs on
//! an instrument you can watch, an inventory belongs on a page you open.

use crate::{
    model::{cover, watch, workspace::Resident},
    view::settings::{self, SettingsWindow, Switch},
};
use bezel::{
    gpui::{AnyElement, Context, Focusable as _, Window, div, prelude::*, px},
    theme::{TextStyle, Theme, Typeset},
    ui::{
        input::{Shape, TextField},
        widgets::{ButtonStyle, Buttons, Scaffolding},
    },
};

/// The least the ceiling may be set to: one cover, rounded up to whole
/// megabytes. Below it the open article would be evicted as it is drawn.
const FLOOR: u64 = cover::RASTER_BYTES / 1_000_000 + 1;

/// How wide the ceiling's dialog sits.
const DIALOG_WIDTH: f32 = 320.;

/// The watch delays the row offers, named for what they buy rather than for
/// what they are. The middle one is [`watch::BOUNCE`], which is what a file
/// that has never been touched carries.
const BOUNCES: [(u64, &str); 3] = [
    (50, "Instant"),
    (watch::BOUNCE, "Balanced"),
    (500, "Relaxed"),
];

/// How many covers a ceiling of `mb` admits — the capacity the cache runs at,
/// which is the ceiling floored to whole pictures.
fn covers_under(mb: u64) -> String {
    match mb * 1_000_000 / cover::RASTER_BYTES {
        1 => "one cover".to_owned(),
        n => format!("{n} covers"),
    }
}

impl SettingsWindow {
    pub(super) fn performance_body(&self, cx: &mut Context<Self>) -> AnyElement {
        let theme = Theme::of(cx).clone();
        div()
            .flex()
            .flex_col()
            .gap(px(settings::GROUP_GAP))
            .child(
                theme
                    .group_box()
                    .child(self.meter_row(cx))
                    .child(self.cover_memory_row(cx))
                    .child(self.watch_bounce_row(cx)),
            )
            .child(self.resident_group(cx))
            .into_any_element()
    }

    fn meter_row(&self, cx: &mut Context<Self>) -> AnyElement {
        let on = self.workspace.read(cx).meter;
        self.switch_row(
            Switch::new(
                "meter",
                "Frame meter",
                "What this window draws while you use it.",
                on,
            )
            .first(true),
            cx,
            |this, cx| {
                this.workspace.update(cx, |workspace, cx| {
                    workspace.meter = !workspace.meter;
                    cx.notify();
                });
            },
        )
    }

    /// The ceiling decoded covers run under. Typed rather than stepped: the
    /// useful values are far apart, and a press each way reaches none of them.
    fn cover_memory_row(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
        let theme = Theme::of(cx).clone();
        let mb = self.workspace.read(cx).settings.cover_memory;
        theme
            .card_row(false)
            .child(
                div()
                    .flex_1()
                    .min_w_0()
                    .flex()
                    .flex_col()
                    .child(theme.row_title("Cover memory"))
                    .child(
                        div()
                            .mt(px(4.))
                            .text_style(TextStyle::Subheadline)
                            .text_color(theme.text_muted)
                            .child(format!("Room for {}.", covers_under(mb))),
                    ),
            )
            .child(
                theme
                    .ghost("cover-memory")
                    .px(px(10.))
                    .py(px(3.))
                    .border_1()
                    .border_color(theme.border)
                    .bg(theme.input_bg)
                    .text_style(TextStyle::Callout)
                    .text_color(theme.text)
                    .child(format!("{mb} MB"))
                    .on_click(
                        cx.listener(|this, _, window, cx| this.edit_cover_memory(window, cx)),
                    ),
            )
    }

    /// How long a project's `.cydonia/` has to go quiet before the watch
    /// re-reads it — see [`crate::model::watch`].
    ///
    /// A segmented control rather than a field: the useful values are three,
    /// and what they are worth saying is what they cost, not what they are.
    /// `settings.toml` still takes any number in the range, and a file carrying
    /// one lights no segment — the note underneath is what says where it sits.
    fn watch_bounce_row(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
        let theme = Theme::of(cx).clone();
        // What the watch will actually wait, not what the file says: the ends
        // of the range are enforced on the way out of it, and a row reporting
        // the number it was given would be reporting one nothing honours.
        let ms = watch::bounce(self.workspace.read(cx).settings.watch_bounce).as_millis();
        theme
            .card_row(false)
            .child(
                div()
                    .flex_1()
                    .min_w_0()
                    .flex()
                    .flex_col()
                    .child(theme.row_title("Watch delay"))
                    .child(
                        div()
                            .mt(px(4.))
                            .text_style(TextStyle::Subheadline)
                            .text_color(theme.text_muted)
                            .child(format!("Re-read after {ms} ms of quiet.")),
                    ),
            )
            .child(
                div()
                    .flex_none()
                    .flex()
                    .flex_row()
                    .gap(px(2.))
                    .p(px(2.))
                    .rounded(px(Theme::button_radius()))
                    .border_1()
                    .border_color(theme.border)
                    .children(BOUNCES.into_iter().map(|(value, label)| {
                        let selected = u128::from(value) == ms;
                        div()
                            .id(("watch-bounce", value as usize))
                            .px(px(10.))
                            .py(px(4.))
                            .rounded(px(Theme::control_radius()))
                            .text_style(TextStyle::Callout)
                            .cursor_pointer()
                            .when(selected, |el| {
                                el.bg(theme.element_active).text_color(theme.text)
                            })
                            .when(!selected, |el| {
                                el.text_color(theme.text_muted)
                                    .hover(|el| el.bg(theme.element_hover))
                            })
                            .child(label)
                            .on_click(cx.listener(move |this, _, _, cx| {
                                this.workspace.update(cx, |workspace, cx| {
                                    workspace.set_watch_bounce(value, cx)
                                });
                                cx.notify();
                            }))
                    })),
            )
    }

    /// Put the ceiling in a field, seeded with what it is now.
    fn edit_cover_memory(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let mb = self.workspace.read(cx).settings.cover_memory;
        let field = cx.new(|cx| {
            let mut field = TextField::new(cx).with_shape(Shape::Line);
            field.set_content(mb.to_string(), cx);
            field
        });
        field.focus_handle(cx).focus(window, cx);
        self.editing = Some(field);
        cx.notify();
    }

    /// Take what was typed, if it is a number. Anything else leaves the
    /// ceiling where it was rather than guessing at what was meant.
    fn save_cover_memory(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(field) = self.editing.take() else {
            return;
        };
        if let Ok(mb) = field.read(cx).content().trim().parse::<u64>() {
            let mb = mb.max(FLOOR);
            self.workspace.update(cx, |workspace, cx| {
                workspace.set_cover_memory(mb, window, cx)
            });
        }
        cx.notify();
    }

    /// The ceiling's dialog, over a scrim that takes the press that dismisses
    /// it. Rendered by the window, so it sits above the scrolling body.
    pub(super) fn cover_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
        let field = self.editing.clone()?;
        let theme = Theme::of(cx).clone();
        Some(
            div()
                .id("cover-scrim")
                .absolute()
                .inset_0()
                .flex()
                .items_center()
                .justify_center()
                .bg(theme.scrim())
                .on_click(cx.listener(|this, _, _, cx| {
                    this.editing = None;
                    cx.notify();
                }))
                .child(
                    div()
                        .id("cover-dialog")
                        .w(px(DIALOG_WIDTH))
                        .flex()
                        .flex_col()
                        .gap(px(settings::LABEL_GAP))
                        .p(px(20.))
                        .rounded(px(Theme::panel_radius()))
                        .border_1()
                        .border_color(theme.border)
                        .bg(theme.surface)
                        // The press that opens a field must not reach the scrim.
                        .on_click(|_, _, cx| cx.stop_propagation())
                        .child(theme.row_title("Cover memory"))
                        .child(
                            div()
                                .text_style(TextStyle::Subheadline)
                                .text_color(theme.text_muted)
                                .child("In megabytes."),
                        )
                        .child(field)
                        .child(
                            div()
                                .flex()
                                .flex_row()
                                .justify_end()
                                .gap(px(8.))
                                .child(
                                    theme
                                        .button("Cancel", ButtonStyle::Ghost, None)
                                        .id("cover-cancel")
                                        .on_click(cx.listener(|this, _, _, cx| {
                                            this.editing = None;
                                            cx.notify();
                                        })),
                                )
                                .child(
                                    theme
                                        .button("Save", ButtonStyle::Prominent, None)
                                        .id("cover-save")
                                        .on_click(cx.listener(|this, _, window, cx| {
                                            this.save_cover_memory(window, cx)
                                        })),
                                ),
                        ),
                )
                .into_any_element(),
        )
    }

    /// What is in memory, counted when the page is drawn. Read-only: this is
    /// the app answering for itself, not another thing to configure.
    fn resident_group(&self, cx: &mut Context<Self>) -> AnyElement {
        let theme = Theme::of(cx).clone();
        let Resident {
            projects,
            articles,
            editors,
            covers,
            sessions,
            items,
        } = self.workspace.read(cx).resident(cx);
        div()
            .flex()
            .flex_col()
            .gap(px(settings::LABEL_GAP))
            .child(theme.field_label("Resident"))
            .child(
                theme
                    .group_box()
                    .child(self.stat_row(
                        true,
                        "Projects",
                        "Open on the rail.",
                        projects.to_string(),
                        cx,
                    ))
                    .child(self.stat_row(
                        false,
                        "Articles",
                        "Holding an editor, of those listed.",
                        format!("{editors} of {articles}"),
                        cx,
                    ))
                    .child(self.stat_row(
                        false,
                        "Covers",
                        "Decoded and held, at twice their declared size.",
                        format!("{covers} · {} MB each", cover::RASTER_BYTES / 1_000_000),
                        cx,
                    ))
                    .child(self.stat_row(
                        false,
                        "Transcripts",
                        "Sessions in memory, and the entries across them.",
                        format!("{sessions} · {items} entries"),
                        cx,
                    )),
            )
            .into_any_element()
    }

    fn stat_row(
        &self,
        first: bool,
        title: &'static str,
        note: &'static str,
        value: String,
        cx: &Context<Self>,
    ) -> impl IntoElement + use<> {
        let theme = Theme::of(cx).clone();
        theme
            .card_row(first)
            .child(
                div()
                    .flex_1()
                    .min_w_0()
                    .flex()
                    .flex_col()
                    .child(theme.row_title(title))
                    .child(
                        div()
                            .mt(px(4.))
                            .text_style(TextStyle::Subheadline)
                            .text_color(theme.text_muted)
                            .child(note),
                    ),
            )
            .child(
                div()
                    .flex_none()
                    .text_style(TextStyle::Callout)
                    .text_color(theme.text)
                    .child(value),
            )
    }
}