Compare commits

...

3 Commits

Author SHA1 Message Date
2322cd00d2 added selectable instance to tui 2026-03-27 12:16:08 +01:00
d746e830fd added instances function to root_config 2026-03-27 12:15:38 +01:00
ac7b07ee3d added basic tui 2026-03-23 14:43:47 +01:00
11 changed files with 1288 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] [dependencies]
anyhow = "1.0.102" anyhow = "1.0.102"
clap = { version = "4.5.60", features = ["derive"] } clap = { version = "4.5.60", features = ["derive"] }
crossterm = "0.29.0"
env_logger = "0.11.9" env_logger = "0.11.9"
globset = "0.4.18" globset = "0.4.18"
libloot = "0.29.0" libloot = "0.29.0"
log = "0.4.29" log = "0.4.29"
quick-xml = { version = "0.39.2", features = ["serde-types", "serialize"] } quick-xml = { version = "0.39.2", features = ["serde-types", "serialize"] }
ratatui = "0.30.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
sevenz-rust2 = { version = "0.20.2" } sevenz-rust2 = { version = "0.20.2" }
thiserror = "2.0.18" thiserror = "2.0.18"

View File

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

View File

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

View File

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

6
src/tui.rs Normal file
View File

@@ -0,0 +1,6 @@
mod app;
mod mod_list;
mod status;
mod instance;
pub use app::run;

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

@@ -0,0 +1,171 @@
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
widgets::{Block, Borders, StatefulWidget, TableState, Widget},
};
use crate::{
tui::{instance::InstanceSelect, mod_list::ModList, status::StatusBar},
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,
selected_instance: Option<String>,
}
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,
selected_instance: None,
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();
}
KeyCode::Right | KeyCode::Char('l') => {
self.next_instance();
}
KeyCode::Left | KeyCode::Char('h') => {
self.prev_instance();
}
_ => {}
}
}
fn exit(&mut self) {
self.exit = true;
}
fn next_instance(&mut self) {
let mut instances = self.root_config.instances();
instances.sort();
if instances.is_empty() {
self.selected_instance = None;
return;
}
let next = match &self.selected_instance {
None => instances.first().cloned(),
Some(curr) => {
let idx = instances.iter().position(|x| x == curr);
match idx {
Some(i) => {
let next_index = (i + 1) % instances.len();
instances.get(next_index).cloned()
}
None => instances.first().cloned(),
}
}
};
self.selected_instance = next;
}
fn prev_instance(&mut self) {
let mut instances = self.root_config.instances();
instances.sort();
if instances.is_empty() {
self.selected_instance = None;
return;
}
let prev = match &self.selected_instance {
None => instances.last().cloned(),
Some(curr) => {
let idx = instances.iter().position(|x| x == curr);
match idx {
Some(i) => {
let prev_index = if i == 0 { instances.len() - 1 } else { i - 1 };
instances.get(prev_index).cloned()
}
None => Some(instances[instances.len() - 1].clone()),
}
}
};
self.selected_instance = prev;
}
}
impl<'a> Widget for &mut App<'a> {
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1), // single line for keybindings
])
.split(area);
InstanceSelect::new(self.selected_instance.clone()).render(chunks[0], buf);
let list_block = Block::default()
.title("Mod list")
.borders(Borders::ALL)
.style(Style::default());
ModList::new(self.root_config).block(list_block).render(
chunks[1],
buf,
&mut self.mod_list_state,
);
StatusBar.render(chunks[2], buf);
}
}

30
src/tui/instance.rs Normal file
View File

@@ -0,0 +1,30 @@
use ratatui::{
style::Style,
widgets::{Block, Borders, Paragraph, Widget},
};
pub struct InstanceSelect {
selected: Option<String>,
}
impl InstanceSelect {
pub fn new(selected: Option<String>) -> Self {
Self { selected }
}
}
impl Widget for InstanceSelect {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
let list_block = Block::default()
.title("Instance")
.borders(Borders::ALL)
.style(Style::default());
Paragraph::new(self.selected.unwrap_or("None".to_owned()))
.block(list_block)
.render(area, buf);
}
}

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);
}
}

12
src/tui/status.rs Normal file
View File

@@ -0,0 +1,12 @@
use ratatui::{text::Line, widgets::Widget};
pub struct StatusBar;
impl Widget for StatusBar {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized {
Line::from("Up Down Left right").render(area, buf);
}
}

View File

@@ -87,10 +87,18 @@ impl RootConfig {
self.mods.get(id).map(|e| e.clone().add_id(id)) 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) { pub fn add_mod(&mut self, new_mod: &ModConfig) {
self.mods.insert(new_mod.id().to_owned(), new_mod.clone()); self.mods.insert(new_mod.id().to_owned(), new_mod.clone());
} }
pub fn instances(&self) -> Vec<String> {
self.instances.keys().cloned().collect()
}
pub fn load_instance_by_id(&self, id: &str) -> Result<ModdedInstance, ConfigReadWriteError> { pub fn load_instance_by_id(&self, id: &str) -> Result<ModdedInstance, ConfigReadWriteError> {
debug!("Loading instance {}", id); debug!("Loading instance {}", id);
let conf = self let conf = self