yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The workspace tab-bar view-model (DESIGN §11 altitude 0, §15 Z9).
//!
//! Workspaces are regime walls — almost invisible: one tab per **named**
//! workspace under the top right, pinned first (in pin order), then name
//! order. Foreign and replay workspaces are real but not regimes, so they
//! live behind the overflow menu rather than widening the wall row; pinning
//! hoists one into the tabs. Pure over injected facts; the shell paints the
//! [`TabBar`] and the + mint beside it.

use crate::binding::{Workspace, WorkspaceKind};
use crate::nav::ws_key;
use std::path::PathBuf;

/// The per-workspace facts a tab is built from — derived by the caller
/// ([`AppModel::tab_bar`](crate::AppModel::tab_bar)) from the classification +
/// attention rollup.
#[derive(Debug, Clone)]
pub struct Item {
    pub ws: Workspace,
    pub attention: usize,
}

/// One tab or overflow entry (§11): the workspace path (the focus target), its
/// display name (the minted name, or the auto-id leaf for foreign/replay), its
/// attention count, whether it is the focused workspace, whether it is pinned
/// (hoisted), and whether it is a read-only replay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tab {
    pub ws: PathBuf,
    pub name: String,
    pub attention: usize,
    pub selected: bool,
    pub pinned: bool,
    pub replay: bool,
}

/// The full derived tab bar (§11): the wall-row `tabs` (pinned hoists + named
/// workspaces) and the `overflow` menu entries (unpinned foreign + replays).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TabBar {
    pub tabs: Vec<Tab>,
    pub overflow: Vec<Tab>,
}

impl TabBar {
    /// The overflow button's aggregate attention badge (§11): the sum over the
    /// menu entries, so a stirring foreign workspace is visible while folded.
    pub fn overflow_attention(&self) -> usize {
        self.overflow.iter().map(|t| t.attention).sum()
    }
}

/// Build the §11 tab bar: pinned first (pin order), then named workspaces
/// (name order); unpinned foreign then replay entries into the overflow (path
/// order — the enumeration's derived order, I9). `focused` marks selection.
pub fn build(items: &[Item], pinned: &[String], focused: Option<&std::path::Path>) -> TabBar {
    let mut tabs: Vec<Tab> = Vec::new();
    for key in pinned {
        if let Some(it) = items.iter().find(|it| ws_key(&it.ws.path) == *key) {
            tabs.push(tab(it, focused, true));
        }
    }
    let mut named: Vec<Tab> = items
        .iter()
        .filter(|it| !is_pinned(it, pinned) && matches!(it.ws.kind, WorkspaceKind::Named { .. }))
        .map(|it| tab(it, focused, false))
        .collect();
    named.sort_by(|a, b| a.name.cmp(&b.name));
    tabs.extend(named);
    let overflow = items
        .iter()
        .filter(|it| !is_pinned(it, pinned) && !matches!(it.ws.kind, WorkspaceKind::Named { .. }))
        .map(|it| tab(it, focused, false))
        .collect();
    TabBar { tabs, overflow }
}

fn is_pinned(it: &Item, pinned: &[String]) -> bool {
    pinned.iter().any(|k| *k == ws_key(&it.ws.path))
}

/// Project one [`Item`] to a [`Tab`]. The display name is the path leaf — for
/// a named workspace this *is* the minted name (§3.1).
fn tab(it: &Item, focused: Option<&std::path::Path>, pinned: bool) -> Tab {
    let name = it
        .ws
        .path
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    Tab {
        ws: it.ws.path.clone(),
        name,
        attention: it.attention,
        selected: focused == Some(it.ws.path.as_path()),
        pinned,
        replay: it.ws.kind == WorkspaceKind::Replay,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn item(path: &str, kind: WorkspaceKind, attention: usize) -> Item {
        Item {
            ws: Workspace {
                path: PathBuf::from(path),
                kind,
            },
            attention,
        }
    }

    fn named(path: &str, name: &str, attention: usize) -> Item {
        item(
            path,
            WorkspaceKind::Named {
                name: name.to_owned(),
            },
            attention,
        )
    }

    #[test]
    fn named_workspaces_tab_in_name_order_and_carry_facts() {
        let items = [
            named("/y/zeta-pug", "zeta-pug", 2),
            named("/y/alba-koi", "alba-koi", 0),
        ];
        let bar = build(&items, &[], Some(Path::new("/y/zeta-pug")));
        let names: Vec<&str> = bar.tabs.iter().map(|t| t.name.as_str()).collect();
        assert_eq!(names, ["alba-koi", "zeta-pug"]);
        assert!(bar.overflow.is_empty());
        let zeta = &bar.tabs[1];
        assert!(zeta.selected && !zeta.pinned && !zeta.replay);
        assert_eq!(zeta.attention, 2);
        assert!(!bar.tabs[0].selected);
    }

    #[test]
    fn foreign_and_replay_go_to_overflow_with_aggregate_attention() {
        let items = [
            named("/y/alba-koi", "alba-koi", 0),
            item("/l/workspaces/20260101T-aa", WorkspaceKind::Foreign, 1),
            item("/l/replays/20260102T-bb", WorkspaceKind::Replay, 2),
        ];
        let bar = build(&items, &[], None);
        assert_eq!(bar.tabs.len(), 1);
        let names: Vec<&str> = bar.overflow.iter().map(|t| t.name.as_str()).collect();
        assert_eq!(names, ["20260101T-aa", "20260102T-bb"]);
        assert!(!bar.overflow[0].replay, "foreign is not a replay");
        assert!(bar.overflow[1].replay, "replay flagged read-only");
        assert_eq!(bar.overflow_attention(), 3, "menu badge sums entries");
    }

    #[test]
    fn pinning_hoists_any_kind_into_the_tabs_in_pin_order() {
        let items = [
            named("/y/alba-koi", "alba-koi", 0),
            item("/l/workspaces/20260101T-aa", WorkspaceKind::Foreign, 0),
            item("/l/replays/20260102T-bb", WorkspaceKind::Replay, 0),
        ];
        let pins = [
            "/l/replays/20260102T-bb".to_owned(),
            "/l/workspaces/20260101T-aa".to_owned(),
        ];
        let bar = build(&items, &pins, None);
        let names: Vec<&str> = bar.tabs.iter().map(|t| t.name.as_str()).collect();
        assert_eq!(
            names,
            ["20260102T-bb", "20260101T-aa", "alba-koi"],
            "pins first in pin order, then named"
        );
        assert!(bar.tabs[0].pinned && bar.tabs[0].replay);
        assert!(bar.overflow.is_empty(), "hoisted entries leave the menu");
    }

    #[test]
    fn a_stale_pin_key_matches_nothing_and_is_dropped() {
        let items = [named("/y/alba-koi", "alba-koi", 0)];
        let bar = build(&items, &["/gone".to_owned()], None);
        assert_eq!(bar.tabs.len(), 1);
        assert!(!bar.tabs[0].pinned);
    }
}