aboutsummaryrefslogtreecommitdiff
path: root/dot_tmux.conf
Commit message (Expand)AuthorAgeFilesLines
* Initial commitpablu2026-03-221-0/+57
use std::fmt::Write as _;

use ratatui::{
    Frame,
    layout::{Alignment, Constraint, Direction, Layout, Spacing},
    prelude::Rect,
    style::{Color, Modifier, Style, Stylize},
    symbols,
    text::{Line, Span, Text},
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
};

use crate::{
    app::{App, ViewState},
    gentoo::Package,
    theme::Theme,
};

pub fn ui(frame: &mut Frame, app: &mut App) {
    frame.render_widget(Block::default().bg(Theme::BG), frame.area());

    let layout = Layout::vertical(vec![Constraint::Length(3), Constraint::Fill(1)]);
    let [tab_bar, rest] = frame.area().layout(&layout);

    render_tab(frame, tab_bar, &app.view);

    match app.view {
        crate::app::ViewState::Dashboard => render_dashboard(frame, rest, app),
        crate::app::ViewState::InstalledPackages => render_installed_packages(frame, rest, app),
    }
}

fn render_tab(frame: &mut Frame, area: Rect, selected_tab: &ViewState) {
    let selected_index = match selected_tab {
        ViewState::Dashboard => 0,
        ViewState::InstalledPackages => 1,
    };

    let tabs = Tabs::new(vec!["Dashboard", "Installed", "Search"])
        .block(Block::default().borders(Borders::ALL).style(Theme::block()))
        .highlight_style(Theme::success())
        .select(selected_index)
        .divider(symbols::DOT)
        .padding(" ", " ");
    frame.render_widget(tabs, area);
}

fn render_dashboard(frame: &mut Frame, area: Rect, app: &App) {
    let logo = "
     ██████╗ ██╗   ██╗██╗███████╗██╗    ██╗
    ██╔═══██╗██║   ██║██║██╔════╝██║    ██║
    ██║   ██║██║   ██║██║█████╗  ██║ █╗ ██║
    ██║▄▄ ██║╚██╗ ██╔╝██║██╔══╝  ██║███╗██║
    ╚██████╔╝ ╚████╔╝ ██║███████╗╚███╔███╔╝
     ╚══▀▀═╝   ╚═══╝  ╚═╝╚══════╝ ╚══╝╚══╝
            gentoo portage dashboard
            ";

    let constraints = [
        Constraint::Length(9),
        Constraint::Percentage(30),
        Constraint::Fill(1),
        Constraint::Length(3),
    ];

    let layout = Layout::vertical(constraints);

    let [logo_top, stats, _fill, key_hints] = area.layout(&layout);

    let [stats_top, stats_middle, stats_bottom] = Layout::vertical([
        Constraint::Fill(1),
        Constraint::Fill(1),
        Constraint::Fill(1),
    ])
    .spacing(Spacing::Overlap(1))
    .areas(stats);

    let splits = Layout::horizontal([
        Constraint::Fill(1),
        Constraint::Fill(1),
        Constraint::Fill(1),
    ])
    .spacing(Spacing::Overlap(1))
    .split(stats_top);

    let logo = Paragraph::new(Text::styled(logo, Theme::title()));
    frame.render_widget(logo, logo_top);

    let keys = Paragraph::new(Span::styled(
        "(q) to quit | (tab) to switch tabs",
        Theme::muted(),
    ))
    .block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Theme::block()),
    );
    frame.render_widget(keys, key_hints);

    let installed_packages_len = app.installed_packages().len().to_string();
    let installed_packages = create_stats("Installed Packages", installed_packages_len.as_str());
    let global_use_flags = create_stats("Global USE Flags", "COMING SOON");
    let repository_state = create_stats("Repository state", "COMING SOON");
    frame.render_widget(installed_packages, splits[0]);
    frame.render_widget(global_use_flags, splits[1]);
    frame.render_widget(repository_state, splits[2]);

    let splits = Layout::horizontal([
        Constraint::Fill(1),
        Constraint::Fill(1),
        Constraint::Fill(1),
    ])
    .spacing(Spacing::Overlap(1))
    .split(stats_middle);

    let world_package_count = app.world_count().to_string();
    let world_packages = create_stats("World Packages", &world_package_count);
    let pending_updates = create_stats("Pending Updates", "COMING SOON");
    let last_emerge = create_stats("Last Emerge", "COMING SOON");

    frame.render_widget(world_packages, splits[0]);
    frame.render_widget(pending_updates, splits[1]);
    frame.render_widget(last_emerge, splits[2]);

    let splits = Layout::horizontal([
        Constraint::Fill(1),
        Constraint::Fill(1),
        Constraint::Fill(1),
    ])
    .spacing(Spacing::Overlap(1))
    .split(stats_bottom);

    let installed_size = human_size(app.total_installed_size());

    let installed_size = create_stats("Installed Size", &installed_size);
    let distfiles_cache = create_stats("Distfiles Cache", "COMING SOON");
    let portage_news = create_stats("Portage News", "COMING SOON");

    frame.render_widget(installed_size, splits[0]);
    frame.render_widget(distfiles_cache, splits[1]);
    frame.render_widget(portage_news, splits[2]);
}

fn human_size(bytes: usize) -> String {
    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];

    let mut size = bytes as f64;
    let mut unit = 0;

    while size >= 1024.0 && unit < UNITS.len() - 1 {
        size /= 1024.0;
        unit += 1;
    }

    if unit == 0 {
        format!("{} {}", bytes, UNITS[unit])
    } else {
        format!("{:.1} {}", size, UNITS[unit])
    }
}

fn create_stats<'a>(title: &'a str, value: &'a str) -> Paragraph<'a> {
    Paragraph::new(vec![
        Line::from(Span::styled(title, Theme::title())),
        Line::from(""),
        Line::from(value),
    ])
    .block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Theme::block())
            .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact),
    )
    .style(Theme::text())
    .alignment(Alignment::Center)
}

fn render_installed_packages(frame: &mut Frame, area: Rect, app: &mut App) {
    let constraints = [Constraint::Fill(1), Constraint::Length(3)];

    let layout = Layout::vertical(constraints);
    let [top, bottom] = area.layout(&layout);

    let split =
        Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(top);

    let split_vert =
        Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(split[1]);

    render_packages(frame, split[0], app);
    render_use_flags(frame, split_vert[0], app);
    render_package_metadata(frame, split_vert[1], app);

    let text = if app.showing_search_window {
        "(esc) to quit search | (enter) to search".to_string()
    } else {
        let mut main_key_hint = "(q) to quit | (j) down | (k) up | (/) to search".to_string();
        if let (Some(current), Some(total)) = (app.current_search_index, app.search_indexes_len) {
            let _ = write!(
                main_key_hint,
                " | (n) for next search | (N) for previous search | Searches found: {} / {}",
                current + 1,
                total
            );
        }

        main_key_hint
    };

    let key_hint = Span::styled(text, Theme::muted());

    let key_notes_footer = Paragraph::new(Line::from(key_hint)).block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Theme::block()),
    );
    frame.render_widget(key_notes_footer, bottom);

    if app.showing_search_window {
        let mut popup_block = Block::default()
            .title("Search")
            .borders(Borders::ALL)
            .border_type(ratatui::widgets::BorderType::Rounded)
            .border_style(Theme::block())
            .style(Style::default().bg(Theme::BG));

        if let Some(found) = app.search_found {
            if found {
                popup_block = popup_block.border_style(Theme::success());
            } else {
                popup_block = popup_block.border_style(Theme::error());
            }
        }

        app.textarea.set_block(popup_block);
        app.textarea.set_style(Theme::muted());
        app.textarea.set_cursor_line_style(Style::default());

        let area = search_popup_rect(70, area);

        frame.render_widget(
            Block::default().style(Style::default().bg(Theme::BG).add_modifier(Modifier::DIM)),
            frame.area(),
        );
        frame.render_widget(Clear, area);
        frame.render_widget(Block::default().style(Style::default().bg(Theme::BG)), area);
        frame.render_widget(&app.textarea, area);
    }
}

fn render_package_metadata(frame: &mut Frame, area: Rect, app: &App) {
    let pkg = app.current_package();

    let bold_style = Theme::title(); //Style::default().add_modifier(Modifier::BOLD);
    let maintainer = Line::from_iter([
        Span::styled("Maintainer: ", bold_style),
        Span::raw(pkg.maintainer.as_deref().unwrap_or("Unknown")),
    ]);

    let version = Line::from_iter([
        Span::styled("Version: ", bold_style),
        Span::raw(&pkg.version),
    ]);

    let repository = Line::from_iter([
        Span::styled("Repository: ", bold_style),
        Span::raw(&pkg.repository),
    ]);

    let license = Line::from_iter([
        Span::styled("License: ", bold_style),
        Span::raw(pkg.license.as_deref().unwrap_or("Unknown")),
    ]);

    let description = Line::from_iter([
        Span::styled("Description: ", bold_style),
        Span::raw(pkg.description.as_deref().unwrap_or("Unknown")),
    ]);

    let size = Line::from_iter([
        Span::styled("Size: ", bold_style),
        Span::raw(human_size(pkg.size)),
    ]);

    let mut lines = vec![maintainer, version, repository, license, description, size];

    let mut homepage = homepage_lines(pkg, bold_style);
    lines.append(&mut homepage);

    let paragraph = Paragraph::new(lines)
        .block(
            Block::default()
                .title("Metadata")
                .borders(Borders::ALL)
                .border_style(Theme::block()),
        )
        .wrap(Wrap { trim: false });

    frame.render_widget(paragraph, area);
}

fn homepage_lines(pkg: &Package, bold_style: Style) -> Vec<Line<'_>> {
    let mut lines = Vec::new();
    lines.push(Line::from(vec![
        Span::styled("Homepage: ", bold_style),
        Span::raw(""),
    ]));

    if let Some(homepages) = &pkg.homepage {
        for url in homepages.iter().filter(|s| !s.is_empty()) {
            lines.push(Line::from(vec![
                Span::raw("  - "), // indentation
                Span::styled(
                    url,
                    Style::default()
                        .fg(Color::LightBlue)
                        .add_modifier(Modifier::UNDERLINED),
                ),
            ]));
        }
    } else {
        lines.push(Line::from(vec![
            Span::raw("  "),
            Span::styled("Unknown", Style::default().fg(Color::DarkGray)),
        ]));
    }

    lines
}

fn search_popup_rect(percent_x: u16, r: Rect) -> Rect {
    // Cut the given rectangle into three vertical pieces
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(10), Constraint::Length(3)])
        .split(r);

    // Then cut the middle vertical piece into three width-wise pieces
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1] // Return the middle chunk
}

fn render_packages(frame: &mut Frame, area: Rect, app: &mut App) {
    let items: Vec<String> = app
        .installed_packages()
        .iter()
        .map(|x| x.name.clone())
        .collect();

    let list = List::new(items)
        .style(Color::White)
        .highlight_style(Theme::selected())
        .highlight_symbol("> ")
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Theme::block())
                .title("Installed packages"),
        );

    frame.render_stateful_widget(list, area, &mut app.list_state);
}

fn render_use_flags(frame: &mut Frame, area: Rect, app: &App) {
    let selected_package = app.current_package();

    let items = selected_package.use_flags.iter().map(|x| {
        let text_style = if x.enabled {
            Theme::success()
        } else if x.default {
            Theme::info()
        } else {
            Theme::error()
        };

        let text_style = if x.default {
            text_style.add_modifier(Modifier::ITALIC)
        } else {
            text_style
        };

        ListItem::new(x.name.clone()).style(text_style)
    });
    let list = List::new(items)
        .style(Color::White)
        .scroll_padding(1)
        .direction(ratatui::widgets::ListDirection::TopToBottom)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("USE Flags")
                .border_style(Theme::block()),
        );

    let mut list_state = ListState::default();
    list_state.select_first();

    frame.render_stateful_widget(list, area, &mut list_state);
}