fission-command-ui 0.14.1

Terminal UI application for the fission command
Documentation
use crate::actions::{select_command_session, SelectCommandSession};
use crate::commands::CommandStatus;
use crate::commands::{CommandSessionId, CommandSnapshot};
use crate::density::UiDensity;
use crate::state::{log_scroll_widget_id, UiState};
use crate::theme::UiPalette;
use fission::prelude::*;

#[derive(Clone)]
pub struct OutputPanel {
    pub width: f32,
    pub height: f32,
}

impl From<OutputPanel> for Widget {
    fn from(component: OutputPanel) -> 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 log_height = density.output_log_height(component.height);
        let log_width = (component.width - 4.0).max(10.0);
        let active = view.state().active_command_session();
        let (title, status, output, scroll_id) = match active {
            Some(session) => (
                session.record.title.clone(),
                session.record.status,
                log_scrollback(session, view, log_height),
                log_scroll_widget_id(session.id),
            ),
            None => (
                "Output".to_string(),
                CommandStatus::Ready,
                ScrollbackView {
                    total_lines: 1,
                    visible_text:
                        "Choose a workflow, confirm the command, and review results here."
                            .to_string(),
                    start_line: 0,
                    visible_lines: 1,
                },
                WidgetId::explicit("cli_ui_log_scrollback_empty"),
            ),
        };
        let status_color = match status {
            CommandStatus::Ready => palette.muted,
            CommandStatus::Running => palette.warning,
            CommandStatus::Ok => palette.success,
            CommandStatus::Failed => palette.error,
        };
        Container::new(Column {
            gap: Some(0.0),
            children: vec![
                Row {
                    gap: Some(2.0),
                    children: vec![
                        Text::new(title).color(palette.text).into(),
                        Text::new(status.label()).color(status_color).into(),
                    ],
                    ..Default::default()
                }
                .into(),
                command_tabs(ctx, view, component.width - 4.0),
                Scroll {
                    id: Some(scroll_id.into()),
                    direction: FlexDirection::Column,
                    width: Some(log_width),
                    height: Some(log_height),
                    show_scrollbar: true,
                    child: Some(scrollback_content(output, palette.muted)),
                    ..Default::default()
                }
                .into(),
            ],
            ..Default::default()
        })
        .width(component.width)
        .height(component.height)
        .padding(density.sidebar_padding())
        .bg(palette.raised)
        .border(palette.border, 1.0)
        .into()
    }
}
struct ScrollbackView {
    total_lines: usize,
    visible_text: String,
    start_line: usize,
    visible_lines: usize,
}

fn log_scrollback(
    session: &CommandSnapshot,
    view: ViewHandle<UiState>,
    log_height: f32,
) -> ScrollbackView {
    let visible_lines = (log_height.floor() as usize).max(1);
    let offset = view
        .runtime()
        .scroll
        .get_offset(log_scroll_widget_id(session.id).into())
        .max(0.0);
    let total_lines = session.record.output.display_line_count().max(1);
    let start_line = (offset.floor() as usize).min(total_lines.saturating_sub(1));
    let visible_text = session
        .record
        .output
        .visible_lines(start_line, visible_lines)
        .join("\n");
    ScrollbackView {
        total_lines,
        visible_text,
        start_line,
        visible_lines,
    }
}

fn command_tabs(ctx: BuildCtxHandle<UiState>, view: ViewHandle<UiState>, width: f32) -> Widget {
    if view.state().command_sessions.is_empty() {
        return Spacer {
            height: Some(0.0),
            ..Default::default()
        }
        .into();
    }

    let palette = UiPalette::for_mode(view.state().theme_mode);
    let max_tabs = 5usize;
    let mut tabs = Vec::new();
    for session in view
        .state()
        .command_sessions
        .iter()
        .rev()
        .take(max_tabs)
        .rev()
    {
        tabs.push(command_tab(session.id, ctx, view));
    }
    if view.state().command_sessions.len() > max_tabs {
        tabs.insert(
            0,
            Text::new(format!(
                "+{}",
                view.state().command_sessions.len() - max_tabs
            ))
            .color(palette.muted)
            .into(),
        );
    }

    Scroll {
        id: Some(WidgetId::explicit("cli_ui_command_tabs")),
        direction: FlexDirection::Row,
        width: Some(width),
        height: Some(1.0),
        show_scrollbar: false,
        child: Some(
            Row {
                gap: Some(1.0),
                children: tabs,
                ..Default::default()
            }
            .into(),
        ),
        ..Default::default()
    }
    .into()
}

fn command_tab(
    session_id: CommandSessionId,
    ctx: BuildCtxHandle<UiState>,
    view: ViewHandle<UiState>,
) -> Widget {
    let Some(session) = view
        .state()
        .command_sessions
        .iter()
        .find(|item| item.id == session_id)
    else {
        return Spacer::default().into();
    };
    let palette = UiPalette::for_mode(view.state().theme_mode);
    let active = view.state().active_command_session_id == Some(session.id);
    let status_marker = match session.record.status {
        CommandStatus::Ready => "-",
        CommandStatus::Running => "*",
        CommandStatus::Ok => "+",
        CommandStatus::Failed => "!",
    };
    let label = format!("{status_marker} {}", session.record.title);
    let action = with_reducer!(
        ctx,
        SelectCommandSession(session.id),
        select_command_session
    );
    Button {
        on_press: Some(action),
        height: Some(1.0),
        padding: Some([0.0; 4]),
        background_fill: Some(Fill::Solid(if active {
            palette.accent
        } else {
            palette.subtle
        })),
        text_color: Some(if active {
            palette.accent_text
        } else {
            palette.text
        }),
        child: Some(Text::new(label).into()),
        ..Default::default()
    }
    .into()
}

fn scrollback_content(output: ScrollbackView, color: Color) -> Widget {
    let bottom_lines = output
        .total_lines
        .saturating_sub(output.start_line.saturating_add(output.visible_lines));
    Column {
        gap: Some(0.0),
        children: vec![
            Spacer {
                height: Some(output.start_line as f32),
                ..Default::default()
            }
            .into(),
            Text::new(output.visible_text).color(color).into(),
            Spacer {
                height: Some(bottom_lines as f32),
                ..Default::default()
            }
            .into(),
        ],
        ..Default::default()
    }
    .into()
}