use crate::engine::{format_size, same_session};
use crate::model::World;
use crossterm::event::{
self, Event, KeyCode, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use std::io::{self, Stdout};
pub struct Args {
pub upgradable: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum FilterMode {
All,
Manual,
Auto,
Flatpak,
}
impl FilterMode {
fn matches(self, world: &World, name: &str) -> bool {
let is_flatpak = world
.packages
.get(name)
.map(|p| p.source == crate::model::Source::Flatpak)
.unwrap_or(false);
match self {
FilterMode::All => true,
FilterMode::Manual => world.is_manual(name) && !is_flatpak,
FilterMode::Auto => !world.is_manual(name) && !is_flatpak,
FilterMode::Flatpak => is_flatpak,
}
}
fn next(self) -> Self {
match self {
FilterMode::All => FilterMode::Manual,
FilterMode::Manual => FilterMode::Auto,
FilterMode::Auto => FilterMode::Flatpak,
FilterMode::Flatpak => FilterMode::All,
}
}
fn label(self) -> &'static str {
match self {
FilterMode::All => "all",
FilterMode::Manual => "manual [M]",
FilterMode::Auto => "auto [A]",
FilterMode::Flatpak => "flatpak [F]",
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Relation {
NeededBy,
DependsOn,
}
impl Relation {
fn toggle(self) -> Self {
match self {
Relation::NeededBy => Relation::DependsOn,
Relation::DependsOn => Relation::NeededBy,
}
}
}
struct Frame {
focus: Option<String>,
pool: Vec<String>,
needed_by: Vec<String>,
depends_on: Vec<String>,
query: String,
selected: usize,
alongside: Vec<String>,
origin: Origin,
}
enum Origin {
None,
Manual,
PulledIn(String),
Untraced,
}
pub fn run(args: Args) {
let world = load_world_with_notice();
let pool = if args.upgradable {
world.upgradable_names_sorted()
} else {
world.all_names_sorted()
};
if pool.is_empty() {
if args.upgradable {
println!("\n Nothing to upgrade - system is up to date.\n");
} else {
println!("\n No packages found.\n");
}
return;
}
let mut app = App {
world,
matcher: Matcher::new(Config::DEFAULT),
filter: FilterMode::All,
relation: Relation::NeededBy,
graph: None,
stack: vec![Frame {
focus: None,
pool,
needed_by: Vec::new(),
depends_on: Vec::new(),
query: String::new(),
selected: 0,
alongside: Vec::new(),
origin: Origin::None,
}],
};
if let Err(e) = app.run_ui() {
eprintln!("whypkg: terminal error: {e}");
std::process::exit(1);
}
}
fn load_world_with_notice() -> World {
eprint!(" loading package data…\r");
let world = crate::commands::load_world();
eprint!(" \r");
world
}
struct App {
world: World,
matcher: Matcher,
filter: FilterMode,
relation: Relation,
stack: Vec<Frame>,
graph: Option<crate::commands::graph::GraphView>,
}
impl App {
fn run_ui(&mut self) -> io::Result<()> {
let mut terminal = setup_terminal()?;
let result = self.event_loop(&mut terminal);
restore_terminal(&mut terminal)?;
result
}
fn event_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
loop {
if self.graph.is_some() {
terminal.draw(|f| {
if let Some(g) = &self.graph {
g.render(f, f.area());
}
})?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c'))
{
return Ok(());
}
let graph_back = matches!(key.code, KeyCode::Esc)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('[')));
match key.code {
_ if graph_back => {
let unwound = self
.graph
.as_mut()
.map(|g| g.back(&self.world))
.unwrap_or(false);
if !unwound {
self.graph = None;
}
}
KeyCode::Char('q') => self.graph = None,
KeyCode::Enter => {
if let Some(g) = &mut self.graph {
g.recenter(&self.world);
}
}
KeyCode::Left | KeyCode::Char('h') => {
if let Some(g) = &mut self.graph {
g.move_horizontal(-1);
}
}
KeyCode::Right | KeyCode::Char('l') => {
if let Some(g) = &mut self.graph {
g.move_horizontal(1);
}
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(g) = &mut self.graph {
g.move_vertical(-1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(g) = &mut self.graph {
g.move_vertical(1);
}
}
_ => {}
}
continue;
}
let visible = self.filtered();
self.clamp_selection(visible.len());
terminal.draw(|f| self.render(f, &visible))?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c'))
{
return Ok(());
}
let escape = matches!(key.code, KeyCode::Esc)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('[')));
match key.code {
_ if escape => {
if self.stack.len() == 1 {
return Ok(());
}
self.stack.pop();
}
KeyCode::Enter => {
if let Some(pkg) = visible.get(self.frame().selected).cloned() {
self.open(pkg);
}
}
KeyCode::Tab => {
self.filter = self.filter.next();
if self.filter == FilterMode::Flatpak && !self.has_flatpak() {
self.filter = self.filter.next();
}
self.frame_mut().selected = 0;
}
KeyCode::Up => self.move_selection(-1, visible.len()),
KeyCode::Down => self.move_selection(1, visible.len()),
KeyCode::Left | KeyCode::Right => {
if self.frame().focus.is_some() {
self.relation = self.relation.toggle();
self.frame_mut().selected = 0;
}
}
KeyCode::Backspace => {
self.frame_mut().query.pop();
self.frame_mut().selected = 0;
}
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match c {
'j' | 'n' => self.move_selection(1, visible.len()),
'k' | 'p' => self.move_selection(-1, visible.len()),
'h' | 'l' if self.frame().focus.is_some() => {
self.relation = self.relation.toggle();
self.frame_mut().selected = 0;
}
'g' => {
let target = self
.frame()
.focus
.clone()
.or_else(|| visible.get(self.frame().selected).cloned());
if let Some(t) = target {
self.graph = Some(crate::commands::graph::GraphView::build(
&self.world,
&t,
));
}
}
_ => {}
}
} else {
self.frame_mut().query.push(c);
self.frame_mut().selected = 0;
}
}
_ => {}
}
}
}
fn has_flatpak(&self) -> bool {
self.world
.packages
.values()
.any(|p| p.source == crate::model::Source::Flatpak)
}
fn frame(&self) -> &Frame {
self.stack.last().unwrap()
}
fn frame_mut(&mut self) -> &mut Frame {
self.stack.last_mut().unwrap()
}
fn open(&mut self, pkg: String) {
let dedup = |src: &[String]| -> Vec<String> {
let mut seen = std::collections::HashSet::new();
src.iter()
.filter(|p| seen.insert((*p).clone()))
.cloned()
.collect()
};
let needed_by = dedup(self.world.rdeps_of(&pkg));
let depends_on = dedup(self.world.deps_of(&pkg));
let origin = if self.world.is_manual(&pkg) {
Origin::Manual
} else {
match crate::engine::bfs_root(&self.world, &pkg) {
Some(path) => Origin::PulledIn(path.last().cloned().unwrap_or_default()),
None => Origin::Untraced,
}
};
let alongside = same_session(&self.world, &pkg);
self.relation = Relation::NeededBy;
self.stack.push(Frame {
focus: Some(pkg),
pool: Vec::new(),
needed_by,
depends_on,
query: String::new(),
selected: 0,
alongside,
origin,
});
}
fn base_list(&self) -> &[String] {
let frame = self.stack.last().unwrap();
if frame.focus.is_none() {
&frame.pool
} else if self.relation == Relation::NeededBy {
&frame.needed_by
} else {
&frame.depends_on
}
}
fn filtered(&mut self) -> Vec<String> {
let (query, base) = {
let base: Vec<String> = self
.base_list()
.iter()
.filter(|n| self.filter.matches(&self.world, n))
.cloned()
.collect();
(self.stack.last().unwrap().query.clone(), base)
};
if query.is_empty() {
return base;
}
let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
let world = &self.world;
let matcher = &mut self.matcher;
let mut buf = Vec::new();
let mut scored: Vec<(u32, String)> = base
.into_iter()
.filter_map(|name| {
let haystack = match world.packages.get(&name) {
Some(p) => {
let mut h = name.clone();
if !p.description.is_empty() {
h.push(' ');
h.push_str(&p.description);
}
if let Some(d) = &p.details {
h.push(' ');
h.push_str(d);
}
h
}
None => name.clone(),
};
let utf32 = Utf32Str::new(&haystack, &mut buf);
pattern.score(utf32, matcher).map(|score| (score, name))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0));
scored.into_iter().map(|(_, name)| name).collect()
}
fn clamp_selection(&mut self, len: usize) {
let sel = &mut self.frame_mut().selected;
if len == 0 {
*sel = 0;
} else if *sel >= len {
*sel = len - 1;
}
}
fn move_selection(&mut self, delta: isize, len: usize) {
if len == 0 {
return;
}
let cur = self.frame().selected as isize;
let next = (cur + delta).clamp(0, len as isize - 1);
self.frame_mut().selected = next as usize;
}
fn render(&self, f: &mut ratatui::Frame, visible: &[String]) {
let frame = self.frame();
let has_dossier = frame.focus.is_some();
let dossier_lines = if frame.focus.is_some() {
self.dossier_lines(frame)
} else {
Vec::new()
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(if has_dossier {
dossier_lines.len() as u16 + 2
} else {
0
}),
Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
.split(f.area());
let mut crumb = vec![Span::styled("whypkg", Style::new().bold().cyan())];
for fr in &self.stack {
if let Some(p) = &fr.focus {
crumb.push(Span::raw(" › "));
crumb.push(Span::styled(p.clone(), Style::new().bold()));
}
}
f.render_widget(Paragraph::new(Line::from(crumb)), chunks[0]);
if has_dossier {
f.render_widget(
Paragraph::new(dossier_lines).block(Block::default().borders(Borders::ALL)),
chunks[1],
);
}
let items: Vec<ListItem> = visible
.iter()
.map(|name| ListItem::new(self.pkg_line(name)))
.collect();
let mut state = ListState::default();
state.select(if visible.is_empty() {
None
} else {
Some(frame.selected)
});
let list = List::new(items)
.highlight_style(Style::new().bg(Color::Indexed(54)).bold())
.highlight_symbol("› ");
f.render_stateful_widget(list, chunks[2], &mut state);
let mode_span = if self.filter == FilterMode::All {
Span::styled(" showing: all", Style::new().dim())
} else {
Span::styled(
format!(" showing: {}", self.filter.label()),
Style::new().cyan(),
)
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" filter ", Style::new().dim()),
Span::raw(frame.query.clone()),
Span::styled("▏", Style::new().cyan()),
mode_span,
])),
chunks[3],
);
let help = if has_dossier {
"Enter open · Esc back · ←/→ needs-it / it-needs · Tab filter · Ctrl-G graph · Ctrl-C quit"
} else {
"type to filter · Tab filter · Enter open · Ctrl-G graph · Esc quit"
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(help, Style::new().dim()))),
chunks[4],
);
}
fn dossier_lines(&self, frame: &Frame) -> Vec<Line<'static>> {
let pkg = frame.focus.as_deref().unwrap_or_default();
let p = self.world.packages.get(pkg);
let dim = Style::new().dim();
let version = match p {
Some(p) => match &p.candidate {
Some(c) => format!("{} → {}", p.version, c),
None => p.version.clone(),
},
None => "unknown".into(),
};
let size = format_size(p.map(|p| p.installed_size).unwrap_or(0));
let installed = match (
p.and_then(|p| p.install_date.clone()),
p.and_then(|p| p.install_epoch),
) {
(Some(date), Some(epoch)) => {
format!("{date} ({})", crate::engine::relative_time(epoch))
}
(Some(date), None) => date,
_ => "unknown".to_string(),
};
let description = p.map(|p| p.description.clone()).unwrap_or_default();
let needed_by = self.world.rdep_count(pkg);
let depends_on = self.world.deps_of(pkg).len();
let needed_by_text = if needed_by == 0 {
if crate::engine::is_kernel_pkg(pkg) {
"nothing - but kernel/firmware, do not remove".to_string()
} else {
"nothing - safe to remove".to_string()
}
} else {
format!("{needed_by} {}", plural(needed_by, "package"))
};
let kv = |k: &str, v: Span<'static>| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {k:<12}"), Style::new().dim()),
v,
])
};
let kv_spans = |k: &str, mut spans: Vec<Span<'static>>| -> Line<'static> {
let mut out = vec![Span::styled(format!(" {k:<12}"), Style::new().dim())];
out.append(&mut spans);
Line::from(out)
};
let origin_spans: Vec<Span<'static>> = match &frame.origin {
Origin::Manual => vec![Span::styled("you installed this", Style::new().green())],
Origin::PulledIn(root) => vec![
Span::styled("pulled in by ", Style::new().yellow()),
Span::styled(root.clone(), Style::new().bold().yellow()),
],
Origin::Untraced => {
vec![Span::styled(
"auto-installed (origin untraced)",
Style::new().yellow(),
)]
}
Origin::None => vec![Span::raw("")],
};
let mut lines = vec![
Line::from(vec![
Span::styled(format!(" {pkg}"), Style::new().bold().white()),
if self.world.is_upgradable(pkg) {
Span::styled(" ↑ upgrade available", Style::new().cyan())
} else {
Span::raw("")
},
]),
Line::from(Span::styled(format!(" {description}"), dim)),
kv_spans("why here", origin_spans),
];
if let Some(d) = p.and_then(|p| p.details.as_deref()) {
let text = truncate(d, 100);
lines.insert(2, Line::from(Span::styled(format!(" {text}"), dim)));
}
if !frame.alongside.is_empty() {
let preview: Vec<&str> = frame.alongside.iter().take(3).map(String::as_str).collect();
let more = frame.alongside.len().saturating_sub(preview.len());
let mut text = preview.join(", ");
if more > 0 {
text.push_str(&format!(", +{more} more"));
}
lines.push(kv("alongside", Span::raw(text)));
}
lines.push(kv("version", Span::raw(version)));
lines.push(kv("size", Span::raw(size)));
lines.push(kv("installed", Span::raw(installed)));
let source = if p.map(|p| p.source) == Some(crate::model::Source::Flatpak) {
let text = match p.and_then(|p| p.remote.as_deref()) {
Some(remote) => format!("flatpak ({remote})"),
None => "flatpak app".to_string(),
};
Some(Span::styled(text, Style::new().blue()))
} else {
match p.map(|p| p.origin) {
Some(crate::model::Origin::Repo) => {
Some(Span::styled("from a repository", Style::new().green()))
}
Some(crate::model::Origin::Local) => {
Some(Span::styled("installed from a local file", Style::new().yellow()))
}
Some(crate::model::Origin::Orphaned) => Some(Span::styled(
"not in any repo (repo removed?)",
Style::new().red(),
)),
_ => None,
}
};
if let Some(span) = source {
lines.push(kv("source", span));
}
let showing = || Span::styled(" ← showing below", Style::new().bold().cyan());
let mut needed = vec![Span::raw(needed_by_text)];
if self.relation == Relation::NeededBy {
needed.push(showing());
}
lines.push(kv_spans("needed by", needed));
let mut depends = vec![Span::raw(format!(
"{depends_on} {}",
plural(depends_on, "package")
))];
if self.relation == Relation::DependsOn {
depends.push(showing());
}
lines.push(kv_spans("depends on", depends));
lines
}
fn pkg_line(&self, name: &str) -> Line<'static> {
let manual = self.world.is_manual(name);
let is_flatpak =
self.world.packages.get(name).map(|p| p.source) == Some(crate::model::Source::Flatpak);
let tag = if is_flatpak {
Span::styled("[F]", Style::new().blue())
} else if manual {
Span::styled("[M]", Style::new().green())
} else {
Span::styled("[A]", Style::new().yellow())
};
let up = if self.world.is_upgradable(name) {
Span::styled("↑", Style::new().cyan())
} else {
Span::raw(" ")
};
let size = format_size(
self.world
.packages
.get(name)
.map(|p| p.installed_size)
.unwrap_or(0),
);
let desc = truncate(
&self
.world
.packages
.get(name)
.map(|p| p.description.clone())
.unwrap_or_default(),
55,
);
Line::from(vec![
tag,
Span::raw(" "),
Span::raw(format!("{name:<34}")),
Span::raw(" "),
up,
Span::raw(format!(" {size:>9} ")),
Span::styled(desc, Style::new().dim()),
])
}
}
fn plural(n: usize, word: &str) -> String {
if n == 1 {
word.to_string()
} else {
format!("{word}s")
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
if matches!(
crossterm::terminal::supports_keyboard_enhancement(),
Ok(true)
) {
let _ = execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
Terminal::new(CrosstermBackend::new(stdout))
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
if matches!(
crossterm::terminal::supports_keyboard_enhancement(),
Ok(true)
) {
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()
}