fission-command-ui 0.14.1

Terminal UI application for the fission command
Documentation
use super::{ActionButton, ButtonTone, OutputPanel};
use crate::actions::{navigate, toggle_theme, Navigate, ToggleTheme};
use crate::density::UiDensity;
use crate::routes::UiRoute;
use crate::state::UiState;
use crate::theme::UiPalette;
use fission::op::{AlignItems, JustifyContent};

use fission::prelude::*;

const NAV_SCROLL_NODE_ID: &str = "cli_ui_nav_scroll";

#[derive(Clone)]
pub struct AppShell {
    pub content: Widget,
}

impl From<AppShell> for Widget {
    fn from(component: AppShell) -> Self {
        let (_ctx, view) = fission::build::current::<UiState>();
        let palette = UiPalette::for_mode(view.state().theme_mode);
        let viewport = view.env().viewport_size;
        let density = UiDensity::new(view.state().compact_mode);
        let width = viewport.width.max(96.0);
        let height = viewport.height.max(28.0);
        let metrics = density.shell_metrics(height);
        let outer_padding = density.outer_padding();
        let sidebar_w = density.sidebar_width();
        let body_gap = density.body_gap();
        let content_w =
            (width - sidebar_w - outer_padding[0] - outer_padding[1] - body_gap).max(48.0);

        Container::new(Column {
            gap: Some(density.shell_gap()),
            children: vec![
                AppHeader.into(),
                Row {
                    gap: Some(body_gap),
                    align_items: AlignItems::Stretch,
                    children: vec![
                        Sidebar {
                            width: sidebar_w,
                            height: metrics.body_h,
                        }
                        .into(),
                        Container::new(component.content.clone())
                            .width(content_w)
                            .height(metrics.body_h)
                            .padding(density.content_padding())
                            .bg(palette.surface)
                            .border(palette.border, 1.0)
                            .into(),
                    ],
                    ..Default::default()
                }
                .into(),
                OutputPanel {
                    width: width - outer_padding[0] - outer_padding[1],
                    height: metrics.footer_h,
                }
                .into(),
            ],
            ..Default::default()
        })
        .width(width)
        .height(height)
        .padding(outer_padding)
        .bg(palette.background)
        .into()
    }
}
#[derive(Clone)]
pub struct AppHeader;

impl From<AppHeader> for Widget {
    fn from(_component: AppHeader) -> Self {
        let (ctx, view) = fission::build::current::<UiState>();
        let palette = UiPalette::for_mode(view.state().theme_mode);
        let density = UiDensity::new(view.state().compact_mode);
        let toggle = with_reducer!(ctx, ToggleTheme, toggle_theme);
        Container::new(Row {
            justify_content: JustifyContent::SpaceBetween,
            align_items: AlignItems::Center,
            children: vec![
                Column {
                    gap: Some(0.0),
                    children: vec![
                        Text::new("Fission command")
                            .color(palette.accent_text)
                            .into(),
                        Text::new(format!(
                            "{}  -  {}",
                            view.state().project_name,
                            view.state().project_status
                        ))
                        .color(palette.accent_text)
                        .into(),
                    ],
                    ..Default::default()
                }
                .into(),
                Row {
                    gap: Some(1.0),
                    children: vec![
                        Text::new(format!("Theme: {}", view.state().theme_mode.label()))
                            .color(palette.accent_text)
                            .into(),
                        ActionButton::new("Switch theme", toggle)
                            .tone(ButtonTone::Neutral)
                            .width(16.0)
                            .into(),
                        Text::new("q / Esc / Ctrl-C asks to exit")
                            .color(palette.accent_text)
                            .into(),
                    ],
                    ..Default::default()
                }
                .into(),
            ],
            ..Default::default()
        })
        .height(density.header_height())
        .padding(density.content_padding())
        .bg(palette.accent)
        .border(palette.accent, 1.0)
        .into()
    }
}
#[derive(Clone)]
pub struct Sidebar {
    pub width: f32,
    pub height: f32,
}

impl From<Sidebar> for Widget {
    fn from(component: Sidebar) -> Self {
        let (ctx, view) = fission::build::current::<UiState>();
        let palette = UiPalette::for_mode(view.state().theme_mode);
        let density = UiDensity::new(view.state().compact_mode);
        let padding = density.sidebar_padding();
        let scroll_id = WidgetId::explicit(NAV_SCROLL_NODE_ID);
        let scroll_height = (component.height - padding[2] - padding[3]).max(1.0);
        let route_height = density.nav_route_height();
        let offset = view.runtime().scroll.get_offset(scroll_id.into()).max(0.0);
        let start_route = ((offset / route_height).floor() as usize)
            .min(UiRoute::SIDEBAR.len().saturating_sub(1));
        let visible_routes = ((scroll_height - 1.0) / route_height).floor().max(1.0) as usize;
        let mut children = vec![
            Text::new("Workflows").color(palette.accent).into(),
            Spacer {
                height: Some(start_route as f32 * route_height),
                ..Default::default()
            }
            .into(),
        ];
        for route in UiRoute::SIDEBAR
            .iter()
            .copied()
            .skip(start_route)
            .take(visible_routes)
        {
            children.push(route_button(route, ctx, view, component.width - 4.0));
        }
        let bottom_routes = UiRoute::SIDEBAR
            .len()
            .saturating_sub(start_route.saturating_add(visible_routes));
        children.push(
            Spacer {
                height: Some(bottom_routes as f32 * route_height),
                ..Default::default()
            }
            .into(),
        );
        Container::new(Scroll {
            id: Some(scroll_id.into()),
            direction: FlexDirection::Column,
            width: Some(component.width - 2.0),
            height: Some(scroll_height),
            show_scrollbar: true,
            child: Some(
                Column {
                    gap: Some(density.nav_gap()),
                    children,
                    ..Default::default()
                }
                .into(),
            ),
            ..Default::default()
        })
        .width(component.width)
        .height(component.height)
        .padding(padding)
        .bg(palette.raised)
        .border(palette.border, 1.0)
        .into()
    }
}
fn route_button(
    route: UiRoute,
    ctx: BuildCtxHandle<UiState>,
    view: ViewHandle<UiState>,
    width: f32,
) -> Widget {
    let action = with_reducer!(ctx, Navigate(route), navigate);
    let tone = if view.state().route == route {
        ButtonTone::Primary
    } else {
        ButtonTone::Neutral
    };
    ActionButton::new(route.title(), action)
        .tone(tone)
        .width(width)
        .into()
}