added basic tui

This commit is contained in:
2026-03-23 14:43:47 +01:00
parent 3f91386763
commit ac7b07ee3d
9 changed files with 1164 additions and 29 deletions

1002
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,13 @@ edition = "2024"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.5.60", features = ["derive"] }
crossterm = "0.29.0"
env_logger = "0.11.9"
globset = "0.4.18"
libloot = "0.29.0"
log = "0.4.29"
quick-xml = { version = "0.39.2", features = ["serde-types", "serialize"] }
ratatui = "0.30.0"
serde = { version = "1.0.228", features = ["derive"] }
sevenz-rust2 = { version = "0.20.2" }
thiserror = "2.0.18"

View File

@@ -19,4 +19,5 @@ pub enum Commands {
LoadOrder { instance: String },
ApiCheck,
Download { url: String },
Tui,
}

View File

@@ -8,3 +8,4 @@ pub mod types;
pub mod unpacker;
pub mod utils;
pub mod actions;
pub mod tui;

View File

@@ -7,10 +7,7 @@ use fomod_manager::{
actions::{
activate_instance, create_loadorder, handle_nxm, insert_mod_to_instance,
resolve_files_for_install,
},
cli::{self, Args},
nexus::NexusAPI,
types::RootConfig,
}, cli::{self, Args}, nexus::NexusAPI, tui, types::RootConfig
};
fn command_activate(
@@ -71,7 +68,7 @@ fn command_download(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Resu
fn setup_logger() {
env_logger::builder()
.filter_level(log::LevelFilter::max())
.filter_level(log::LevelFilter::Off)
.format_timestamp(None)
.filter_module("ureq_proto::util", log::LevelFilter::Debug)
.filter_module("rustls::client::hs", log::LevelFilter::Debug)
@@ -102,6 +99,9 @@ fn main() -> Result<(), Box<dyn Error>> {
cli::Commands::Download { url } => {
command_download(&mut root_config, &url)?;
}
cli::Commands::Tui => {
tui::run(&mut root_config)?;
},
}
Ok(())

4
src/tui.rs Normal file
View File

@@ -0,0 +1,4 @@
mod app;
mod mod_list;
pub use app::run;

95
src/tui/app.rs Normal file
View File

@@ -0,0 +1,95 @@
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
DefaultTerminal, Frame,
layout::Rect,
style::Style,
widgets::{Block, Borders, StatefulWidget, TableState, Widget},
};
use crate::{tui::mod_list::ModList, types::RootConfig};
pub fn run(root_config: &mut RootConfig) -> anyhow::Result<()> {
ratatui::run(|terminal| App::new(root_config).run(terminal))?;
Ok(())
}
#[derive(Debug)]
struct App<'a> {
root_config: &'a mut RootConfig,
exit: bool,
mod_list_state: TableState,
}
impl<'a> App<'a> {
fn new(root_config: &'a mut RootConfig) -> Self {
let mut state = TableState::default();
state.select(Some(0)); // select first row by default
Self {
root_config,
mod_list_state: state,
exit: false,
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
terminal.clear()?;
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') => self.exit(),
KeyCode::Up | KeyCode::Char('k') => {
self.mod_list_state.select_previous();
}
KeyCode::Down | KeyCode::Char('j') => {
self.mod_list_state.select_next();
}
_ => {}
}
}
fn exit(&mut self) {
self.exit = true;
}
}
impl<'a> Widget for &mut App<'a> {
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
let list_block = Block::default()
.title("Mod list")
.borders(Borders::ALL)
.style(Style::default());
ModList::new(self.root_config).block(list_block).render(
area,
buf,
&mut self.mod_list_state,
);
}
}

74
src/tui/mod_list.rs Normal file
View File

@@ -0,0 +1,74 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Cell, Row, StatefulWidget, Table, TableState},
};
use crate::types::{ModConfig, RootConfig};
#[derive(Debug)]
pub struct ListItem<'a> {
mod_config: &'a ModConfig,
id: &'a str,
}
#[derive(Debug)]
pub struct ModList<'a> {
items: Vec<ListItem<'a>>,
block: Option<Block<'a>>,
}
impl<'a> ModList<'a> {
pub fn new(root_config: &'a RootConfig) -> Self {
let mut items: Vec<_> = root_config
.mods()
.iter()
.map(|(id, config)| ListItem {
id,
mod_config: config,
})
.collect();
items.sort_by_key(|item| item.id);
Self { items, block: None }
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
}
impl<'a> StatefulWidget for ModList<'a> {
type State = TableState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let rows: Vec<Row> = self
.items
.iter()
.map(|item| {
Row::new(vec![
Cell::from(item.mod_config.name().unwrap_or(item.id)),
Cell::from(item.id),
])
})
.collect();
let table = Table::new(rows, [Constraint::Fill(1), Constraint::Fill(1)])
.row_highlight_style(
Style::default()
.fg(Color::Yellow)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
let table = match self.block {
Some(b) => table.block(b),
None => table,
};
StatefulWidget::render(table, area, buf, state);
}
}

View File

@@ -87,6 +87,10 @@ impl RootConfig {
self.mods.get(id).map(|e| e.clone().add_id(id))
}
pub fn mods(&self) -> &HashMap<String, ModConfig> {
&self.mods
}
pub fn add_mod(&mut self, new_mod: &ModConfig) {
self.mods.insert(new_mod.id().to_owned(), new_mod.clone());
}