Compare commits

...

7 Commits

12 changed files with 1360 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(())

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;

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

@@ -0,0 +1,137 @@
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use log::error;
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Direction, Layout, Rect},
widgets::{StatefulWidget, Widget},
};
use crate::{
tui::{
instance::{InstanceSelect, InstanceSelectState},
mod_list::{ModList, ModListState},
status::StatusBar,
},
types::{ModdedInstance, 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,
loaded_instance: Option<ModdedInstance>,
exit: bool,
mod_list_state: ModListState,
selected_instance_state: InstanceSelectState,
}
impl<'a> App<'a> {
fn new(root_config: &'a mut RootConfig) -> Self {
let mut mod_list_state = ModListState::new();
mod_list_state.update_list(root_config, None);
Self {
root_config,
loaded_instance: None,
exit: false,
mod_list_state,
selected_instance_state: InstanceSelectState::new(),
}
}
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_prev();
}
KeyCode::Down | KeyCode::Char('j') => {
self.mod_list_state.select_next();
}
KeyCode::Right | KeyCode::Char('l') => {
self.selected_instance_state.next_instance(self.root_config);
self.load_instance();
}
KeyCode::Left | KeyCode::Char('h') => {
self.selected_instance_state.prev_instance(self.root_config);
self.load_instance();
}
_ => {}
}
}
fn exit(&mut self) {
self.exit = true;
}
fn load_instance(&mut self) {
let Some(selected) = self.selected_instance_state.instance() else {
return;
};
match self.root_config.load_instance_by_id(selected) {
Ok(instance) => {
self.loaded_instance = Some(instance);
self.mod_list_state
.update_list(self.root_config, self.loaded_instance.as_ref());
}
Err(err) => {
error!("Failed to load instance: {err}");
self.exit();
}
}
}
}
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),
])
.split(area);
InstanceSelect.render(chunks[0], buf, &mut self.selected_instance_state);
ModList.render(chunks[1], buf, &mut self.mod_list_state);
StatusBar.render(chunks[2], buf);
}
}

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

@@ -0,0 +1,92 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};
use crate::types::RootConfig;
#[derive(Debug)]
pub struct InstanceSelectState {
selected: Option<String>,
}
impl InstanceSelectState {
pub fn new() -> Self {
Self { selected: None }
}
pub fn instance(&self) -> Option<&str> {
self.selected.as_deref()
}
pub fn next_instance(&mut self, root_config: &RootConfig) {
let mut instances = root_config.instances();
instances.sort();
if instances.is_empty() {
self.selected = None;
return;
}
let next = match &self.selected {
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 = next;
}
pub fn prev_instance(&mut self, root_config: &RootConfig) {
let mut instances = root_config.instances();
instances.sort();
if instances.is_empty() {
self.selected = None;
return;
}
let prev = match &self.selected {
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 = prev;
}
}
pub struct InstanceSelect;
impl StatefulWidget for InstanceSelect {
type State = InstanceSelectState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let list_block = Block::default()
.title("Instance")
.borders(Borders::ALL)
.style(Style::default());
Paragraph::new(state.selected.clone().unwrap_or("None".to_owned()))
.block(list_block)
.render(area, buf);
}
}

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

@@ -0,0 +1,114 @@
use std::collections::HashSet;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, Row, StatefulWidget, Table, TableState},
};
use crate::types::{ModConfig, ModdedInstance, RootConfig};
#[derive(Debug)]
pub struct ModListState {
table_state: TableState,
items: Vec<ListItem>,
}
impl ModListState {
pub fn new() -> Self {
Self {
table_state: TableState::new(),
items: Vec::new(),
}
}
pub fn select_next(&mut self) {
self.table_state.select_next();
}
pub fn select_prev(&mut self) {
self.table_state.select_previous();
}
pub fn update_list(
&mut self,
root_config: &RootConfig,
loaded_instance: Option<&ModdedInstance>,
) {
let instance_game_type = loaded_instance
.and_then(|e| root_config.game_by_id(e.game_id()))
.map(|e| e.game_type());
let included_ids: Option<HashSet<_>> =
loaded_instance.map(|instance| instance.mods().iter().map(|m| m.mod_id()).collect());
let mut items: Vec<_> = root_config
.mods()
.iter()
.filter(|e| instance_game_type.clone().is_none_or(|gt| e.1.game() == gt))
.map(|(id, config)| ListItem {
id: id.to_owned(),
mod_config: config.clone(),
included: included_ids
.as_ref()
.is_some_and(|set| set.contains(id.as_str())),
})
.collect();
items.sort_by_key(|item| item.id.clone());
self.items = items;
}
}
#[derive(Debug)]
struct ListItem {
mod_config: ModConfig,
id: String,
included: bool,
}
#[derive(Debug)]
pub struct ModList;
impl StatefulWidget for ModList {
type State = ModListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let block = Block::default()
.title("Mod list")
.borders(Borders::ALL)
.style(Style::default());
let rows: Vec<Row> = state
.items
.iter()
.map(|item| {
Row::new(vec![
Cell::from(item.mod_config.name().unwrap_or(&item.id)),
Cell::from(item.id.as_str()),
Cell::from(if item.included { "" } else { "" }),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
],
)
.row_highlight_style(
Style::default()
.fg(Color::Yellow)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.block(block);
StatefulWidget::render(table, area, buf, &mut state.table_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

@@ -93,6 +93,10 @@ impl ModConfig {
pub fn nexus_id(&self) -> Option<&NexusID> {
self.nexus_id.as_ref()
}
pub fn game(&self) -> GameType {
self.game.clone()
}
}
fn is_false(b: &bool) -> bool {

View File

@@ -87,10 +87,18 @@ 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());
}
pub fn instances(&self) -> Vec<String> {
self.instances.keys().cloned().collect()
}
pub fn load_instance_by_id(&self, id: &str) -> Result<ModdedInstance, ConfigReadWriteError> {
debug!("Loading instance {}", id);
let conf = self