├── Cargo.toml ├── LICENSE ├── README.md ├── asset ├── def │ ├── aura.ron │ └── item.ron └── sprite │ └── icon │ ├── blue_potion.png │ ├── cheese.png │ ├── datadisk.png │ ├── green_potion.png │ ├── lava_sword.png │ ├── lightning.png │ ├── orange_potion.png │ ├── red_potion.png │ ├── red_potion_background.png │ ├── shoe.png │ └── unknown.png ├── demo.png └── src ├── game ├── mechanic │ ├── Cargo.toml │ ├── src │ │ ├── aura │ │ │ ├── aura.rs │ │ │ └── mod.rs │ │ ├── item │ │ │ ├── equipment.rs │ │ │ ├── item.rs │ │ │ └── mod.rs │ │ └── lib.rs │ └── test │ │ └── data │ │ ├── test_aura.ron │ │ └── test_item.ron └── system │ ├── Cargo.toml │ └── src │ ├── asset │ ├── asset_lib.rs │ └── mod.rs │ └── lib.rs └── terminal ├── main.rs ├── screen ├── Cargo.toml └── src │ ├── database.rs │ ├── lib.rs │ └── menu.rs └── system ├── Cargo.toml └── src ├── lib.rs ├── terminal_image.rs ├── theme.rs ├── tui.rs └── window.rs /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "asset-editor" 3 | version = "0.1.0" 4 | edition = "2021" 5 | default-run = "terminal" 6 | 7 | # Enables maximum optimization for dependencies. 8 | # Slower clean build time, but will run much faster. 9 | [profile.dev.package."*"] 10 | opt-level = 3 11 | 12 | # Enables some optimzations. 13 | [profile.dev] 14 | opt-level = 1 15 | 16 | [workspace] 17 | members = ["src/game/mechanic"] 18 | 19 | [[bin]] 20 | name = "terminal" 21 | path = "src/terminal/main.rs" 22 | 23 | [workspace.dependencies] 24 | game_mechanic = { path = "src/game/mechanic/" } 25 | game_system = { path = "src/game/system/" } 26 | term_screen = { path = "src/terminal/screen" } 27 | term_system = { path = "src/terminal/system" } 28 | bevy_reflect = { version = "0.15.3" } 29 | crossterm = "0.28.1" 30 | ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] } 31 | image = "0.25.5" 32 | serde = { version = "1", features = ["derive"] } 33 | ron = "0.8.1" 34 | 35 | [dependencies] 36 | game_mechanic = { workspace = true } 37 | game_system = { workspace = true } 38 | term_screen = { workspace = true } 39 | term_system = { workspace = true } 40 | bevy_reflect = { workspace = true } 41 | ron = { workspace = true } 42 | serde = { workspace = true } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yaada 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A TUI asset editor used to create and modify assets for games written in Rust. In the context of this tool, "assets" refers to your typical game objects, like items, abilities and characters. 2 | 3 | ![image info](./demo.png) 4 | 5 | [Click here to see a short demo](https://www.youtube.com/watch?v=EBW31fYh9h8) 6 | 7 | ## Overview 8 | This tool is incomplete, buggy, and generally follows *worst* practices. Use this code at your own risk! A separate, more bespoke version of this tool is being developed alongside [aark:net](https://www.youtube.com/@yaadayaada), which is a 3D Action RPG that I'm developing using Godot + Rust. I don't intend to regularly update this repo, nor accept pull requests, but might release a more complete, generic version in the future. 9 | 10 | This tool requires that you use a terminal emulator that allows you to set arbitrary foreground and background colors for each cell, and key presses involving modifier keys, both of which are not features present in all terminals. You are likely to have the most luck using [Alacritty](https://alacritty.org/) which is the terminal emulator I am developing this tool on. [Kitty](https://sw.kovidgoyal.net/kitty/) should also work. 11 | 12 | The tool also has a system in place to make it easy to add additional tools, represented as different `Window`s. Try adding your own! 13 | 14 | ## Running the Asset Editor 15 | ``` 16 | git clone git@github.com:YaadaYaada/tui-asset-editor.git 17 | cargo run 18 | ``` 19 | 20 | ## Controls 21 | 22 | ``` 23 | ESC -> Exit Window 24 | Enter -> Select Window or Edit Field 25 | Tab -> Cycle Subwindows 26 | Arrow Keys -> Navigate Within Subwindows 27 | Shift Arrow Keys (Up and Down) -> Change selected detail field 28 | ``` 29 | -------------------------------------------------------------------------------- /asset/def/aura.ron: -------------------------------------------------------------------------------- 1 | ( 2 | next_id: 4, 3 | defs: [ 4 | ( 5 | id: 0, 6 | name: "Well Fed", 7 | icon: "sprite/icon/cheese.png", 8 | duration: 3600.0, 9 | aura_type: None, 10 | rules_text: "You feel full! Your fortitudeness is through the roof.", 11 | ), 12 | ( 13 | id: 1, 14 | name: "Shocked", 15 | icon: "sprite/icon/lightning.png", 16 | duration: 10.0, 17 | aura_type: Magic, 18 | rules_text: "Dealing lightning damage periodically", 19 | ), 20 | ( 21 | id: 2, 22 | name: "Haste", 23 | icon: "sprite/icon/shoe.png", 24 | duration: 4000.0, 25 | aura_type: Magic, 26 | rules_text: "You\'re feeling exceedingly speedy. Haste increased by 30%", 27 | ), 28 | ( 29 | id: 3, 30 | name: "Ice Shield", 31 | icon: "sprite/icon/unknown.png", 32 | duration: 30.0, 33 | aura_type: Magic, 34 | rules_text: "You are protected by a barrier of ice. The next attack dealt to you will slow the attacker\'s attack speed by 25%.", 35 | ), 36 | ( 37 | id: 4, 38 | name: "Smolder", 39 | icon: "sprite/icon/unknown.png", 40 | duration: 15.0, 41 | aura_type: Magic, 42 | rules_text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce porta ac sapien eget pulvinar.", 43 | ), 44 | ], 45 | ) -------------------------------------------------------------------------------- /asset/def/item.ron: -------------------------------------------------------------------------------- 1 | ( 2 | next_id: 1, 3 | defs: [ 4 | ( 5 | id: 0, 6 | name: "Red Potion", 7 | rules_text: "", 8 | flavor_text: "A vibrant red potion. Probably safe to drink.", 9 | icon: "sprite/icon/red_potion.png", 10 | item_type: Miscellaneous, 11 | item_rarity: Common, 12 | max_stack: 60, 13 | buy_value: 10, 14 | sell_value: 5, 15 | equipment_def: ( 16 | slot: None, 17 | armor: 0, 18 | ), 19 | ), 20 | ( 21 | id: 1, 22 | name: "Shoe", 23 | rules_text: "", 24 | flavor_text: "A super rad shoe. Unfortunately the second one is nowhere to be found.", 25 | icon: "sprite/icon/shoe.png", 26 | item_type: Equipment, 27 | item_rarity: Uncommon, 28 | max_stack: 1, 29 | buy_value: 200, 30 | sell_value: 100, 31 | equipment_def: ( 32 | slot: Feet, 33 | armor: 5, 34 | ), 35 | ), 36 | ], 37 | ) -------------------------------------------------------------------------------- /asset/sprite/icon/blue_potion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/blue_potion.png -------------------------------------------------------------------------------- /asset/sprite/icon/cheese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/cheese.png -------------------------------------------------------------------------------- /asset/sprite/icon/datadisk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/datadisk.png -------------------------------------------------------------------------------- /asset/sprite/icon/green_potion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/green_potion.png -------------------------------------------------------------------------------- /asset/sprite/icon/lava_sword.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/lava_sword.png -------------------------------------------------------------------------------- /asset/sprite/icon/lightning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/lightning.png -------------------------------------------------------------------------------- /asset/sprite/icon/orange_potion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/orange_potion.png -------------------------------------------------------------------------------- /asset/sprite/icon/red_potion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/red_potion.png -------------------------------------------------------------------------------- /asset/sprite/icon/red_potion_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/red_potion_background.png -------------------------------------------------------------------------------- /asset/sprite/icon/shoe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/shoe.png -------------------------------------------------------------------------------- /asset/sprite/icon/unknown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/asset/sprite/icon/unknown.png -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YaadaYaada/tui-asset-editor/e7d636c10d91f28eb980bf6826272ad710fad3a8/demo.png -------------------------------------------------------------------------------- /src/game/mechanic/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "game_mechanic" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | game_system = { workspace = true } 8 | serde = { workspace = true } 9 | ron = { workspace = true } 10 | bevy_reflect = { workspace = true } 11 | -------------------------------------------------------------------------------- /src/game/mechanic/src/aura/aura.rs: -------------------------------------------------------------------------------- 1 | use bevy_reflect::Reflect; 2 | use game_system::asset::asset_lib::AssetLib; 3 | use ron; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::HashMap; 6 | use std::fmt; 7 | use std::fs::{File, OpenOptions}; 8 | use std::io::{Read, Write}; 9 | use std::str::FromStr; 10 | use std::sync::Arc; 11 | 12 | #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize, Reflect)] 13 | pub enum AuraType { 14 | Physical, 15 | Magic, 16 | Poison, 17 | None, 18 | } 19 | 20 | #[derive(Debug, Deserialize, Serialize)] 21 | struct AuraRon { 22 | next_id: u32, 23 | defs: Vec, 24 | } 25 | 26 | #[derive(Debug, Default)] 27 | pub struct AuraLib { 28 | next_id: u32, 29 | name_map: HashMap, 30 | id_map: HashMap, 31 | pub defs: Vec>, 32 | } 33 | 34 | #[derive(Debug, PartialEq, Clone, Deserialize, Serialize, Reflect)] 35 | pub struct AuraDef { 36 | pub id: u32, 37 | pub name: String, 38 | pub icon: String, 39 | pub duration: f32, 40 | pub aura_type: AuraType, 41 | pub rules_text: String, 42 | } 43 | 44 | #[derive(Debug)] 45 | pub struct Aura { 46 | pub def: Arc, 47 | } 48 | 49 | impl FromStr for AuraType { 50 | type Err = (); 51 | 52 | fn from_str(input: &str) -> Result { 53 | match input { 54 | "Physical" => Ok(AuraType::Physical), 55 | "Magic" => Ok(AuraType::Magic), 56 | "Poison" => Ok(AuraType::Poison), 57 | "None" => Ok(AuraType::None), 58 | _ => Err(()), 59 | } 60 | } 61 | } 62 | 63 | impl fmt::Display for AuraType { 64 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 65 | write!(f, "{:?}", self) 66 | } 67 | } 68 | 69 | impl AssetLib for AuraLib { 70 | fn new(path: &str) -> Self { 71 | let mut file = File::open(path).unwrap(); 72 | let mut data = String::new(); 73 | file.read_to_string(&mut data).unwrap(); 74 | let aura_ron: AuraRon = ron::from_str(&data).expect("RON was not well-formatted"); 75 | 76 | let mut name_map = HashMap::new(); 77 | let mut id_map = HashMap::new(); 78 | let mut defs: Vec> = vec![]; 79 | for (i, def) in aura_ron.defs.into_iter().enumerate() { 80 | name_map.insert(def.name.clone(), i); 81 | id_map.insert(def.id, i); 82 | defs.push(Arc::new(def)); 83 | } 84 | AuraLib { 85 | next_id: aura_ron.next_id, 86 | name_map, 87 | id_map, 88 | defs, 89 | } 90 | } 91 | 92 | fn save(&self, path: &str) { 93 | let mut defs = vec![]; 94 | for def in self.defs.clone() { 95 | defs.push((*def).clone()); 96 | } 97 | let aura_ron = AuraRon { 98 | next_id: self.next_id, 99 | defs, 100 | }; 101 | 102 | let mut aura_def_file = OpenOptions::new() 103 | .truncate(true) 104 | .write(true) 105 | .create(true) 106 | .open(path) 107 | .unwrap(); 108 | let _ = aura_def_file.write( 109 | ron::ser::to_string_pretty(&aura_ron, ron::ser::PrettyConfig::default()) 110 | .unwrap() 111 | .as_bytes(), 112 | ); 113 | } 114 | } 115 | 116 | impl AuraLib { 117 | pub fn id(&self, id: u32) -> &AuraDef { 118 | &self.defs[self.id_map[&id]] 119 | } 120 | 121 | pub fn name(&self, name: String) -> &Arc { 122 | &self.defs[self.name_map[&name]] 123 | } 124 | 125 | pub fn update_def(&mut self, def: Arc) { 126 | let id = &def.id.clone(); 127 | self.defs[self.id_map[id]] = def 128 | } 129 | } 130 | 131 | impl Aura { 132 | pub fn new(def: &Arc) -> Aura { 133 | Aura { def: def.clone() } 134 | } 135 | } 136 | 137 | #[cfg(test)] 138 | mod tests { 139 | use super::*; 140 | use crate::prelude::MECHANIC_TEST_DIR; 141 | 142 | #[test] 143 | fn auralib_load_and_access() { 144 | let aura_lib = AuraLib::new(&format!("{}/test/data/test_aura.ron", MECHANIC_TEST_DIR)); 145 | let expected_aura_def = AuraDef { 146 | name: "Well Fed".to_string(), 147 | icon: "sprite/icon/cheese.png".to_string(), 148 | id: 0, 149 | aura_type: AuraType::None, 150 | duration: 60.0 * 60.0, 151 | rules_text: "You feel full! Your fortitudeness is through the roof.".to_string(), 152 | }; 153 | assert_eq!(*aura_lib.id(0), expected_aura_def); 154 | assert_eq!(aura_lib.name("Shocked".to_string()).name, "Shocked"); 155 | } 156 | 157 | #[test] 158 | fn aura_new() { 159 | let aura_lib = AuraLib::new(&format!("{}/test/data/test_aura.ron", MECHANIC_TEST_DIR)); 160 | let _aura1 = Aura::new(aura_lib.name("Shocked".to_string())); 161 | let _aura2 = Aura::new(aura_lib.name("Shocked".to_string())); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/game/mechanic/src/aura/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod aura; 2 | -------------------------------------------------------------------------------- /src/game/mechanic/src/item/equipment.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, str::FromStr}; 2 | 3 | use bevy_reflect::Reflect; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize, Reflect)] 7 | pub enum EquipmentSlot { 8 | MainHand, 9 | OffHand, 10 | Head, 11 | Chest, 12 | Waist, 13 | Hands, 14 | Legs, 15 | Feet, 16 | Finger, 17 | Neck, 18 | Artifact, 19 | Accessory, 20 | None, 21 | } 22 | 23 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Reflect)] 24 | pub struct EquipmentDef { 25 | pub slot: EquipmentSlot, 26 | pub armor: u32, 27 | } 28 | 29 | impl fmt::Display for EquipmentSlot { 30 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 31 | write!(f, "{:?}", self) 32 | } 33 | } 34 | 35 | impl FromStr for EquipmentSlot { 36 | type Err = (); 37 | 38 | fn from_str(input: &str) -> Result { 39 | match input { 40 | "MainHand" => Ok(EquipmentSlot::MainHand), 41 | "OffHand" => Ok(EquipmentSlot::OffHand), 42 | "Head" => Ok(EquipmentSlot::Head), 43 | "Chest" => Ok(EquipmentSlot::Chest), 44 | "Waist" => Ok(EquipmentSlot::Waist), 45 | "Hands" => Ok(EquipmentSlot::Hands), 46 | "Legs" => Ok(EquipmentSlot::Legs), 47 | "Feet" => Ok(EquipmentSlot::Feet), 48 | "Finger" => Ok(EquipmentSlot::Finger), 49 | "Neck" => Ok(EquipmentSlot::Neck), 50 | "Artifact" => Ok(EquipmentSlot::Artifact), 51 | "Accessory" => Ok(EquipmentSlot::Accessory), 52 | "None" => Ok(EquipmentSlot::None), 53 | _ => Err(()), 54 | } 55 | } 56 | } 57 | 58 | impl Default for EquipmentDef { 59 | fn default() -> Self { 60 | EquipmentDef { 61 | slot: EquipmentSlot::None, 62 | armor: 0, 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/game/mechanic/src/item/item.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt, 3 | fs::{File, OpenOptions}, 4 | io::{Read, Write}, 5 | str::FromStr, 6 | sync::Arc, 7 | }; 8 | 9 | use bevy_reflect::Reflect; 10 | use game_system::prelude::AssetLib; 11 | use serde::{Deserialize, Serialize}; 12 | use std::collections::HashMap; 13 | 14 | use super::equipment::EquipmentDef; 15 | 16 | #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize, Reflect)] 17 | pub enum ItemType { 18 | Equipment, 19 | Consumable, 20 | Material, 21 | Miscellaneous, 22 | } 23 | 24 | impl fmt::Display for ItemType { 25 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 26 | write!(f, "{:?}", self) 27 | } 28 | } 29 | 30 | #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize, Reflect)] 31 | pub enum ItemRarity { 32 | Junk, 33 | Common, 34 | Uncommon, 35 | Rare, 36 | Epic, 37 | Mythical, 38 | } 39 | 40 | impl fmt::Display for ItemRarity { 41 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 42 | write!(f, "{:?}", self) 43 | } 44 | } 45 | 46 | #[derive(Debug, Deserialize, Serialize)] 47 | struct ItemRon { 48 | next_id: u32, 49 | defs: Vec, 50 | } 51 | 52 | #[derive(Debug, Default)] 53 | pub struct ItemLib { 54 | next_id: u32, 55 | name_map: HashMap, 56 | id_map: HashMap, 57 | pub defs: Vec>, 58 | } 59 | 60 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Reflect)] 61 | pub struct ItemDef { 62 | pub id: u32, 63 | pub name: String, 64 | pub rules_text: String, 65 | pub flavor_text: String, 66 | pub icon: String, 67 | pub item_type: ItemType, 68 | pub item_rarity: ItemRarity, 69 | pub max_stack: u32, 70 | pub buy_value: u32, 71 | pub sell_value: u32, 72 | #[serde(default)] 73 | pub equipment_def: EquipmentDef, 74 | } 75 | 76 | #[derive(Debug)] 77 | pub struct Item { 78 | pub def: Arc, 79 | pub text: String, 80 | } 81 | 82 | impl FromStr for ItemType { 83 | type Err = (); 84 | 85 | fn from_str(input: &str) -> Result { 86 | match input { 87 | "Equipment" => Ok(ItemType::Equipment), 88 | "Consumable" => Ok(ItemType::Consumable), 89 | "Miscellaneous" => Ok(ItemType::Miscellaneous), 90 | _ => Err(()), 91 | } 92 | } 93 | } 94 | 95 | impl FromStr for ItemRarity { 96 | type Err = (); 97 | 98 | fn from_str(input: &str) -> Result { 99 | match input { 100 | "Junk" => Ok(ItemRarity::Junk), 101 | "Common" => Ok(ItemRarity::Common), 102 | "Uncommon" => Ok(ItemRarity::Uncommon), 103 | "Rare" => Ok(ItemRarity::Rare), 104 | "Epic" => Ok(ItemRarity::Epic), 105 | "Mythical" => Ok(ItemRarity::Mythical), 106 | _ => Err(()), 107 | } 108 | } 109 | } 110 | 111 | impl AssetLib for ItemLib { 112 | fn new(path: &str) -> Self { 113 | let mut file = File::open(path).unwrap(); 114 | let mut data = String::new(); 115 | file.read_to_string(&mut data).unwrap(); 116 | let item_ron: ItemRon = ron::from_str(&data).expect("RON was not well-formatted"); 117 | 118 | let mut name_map = HashMap::new(); 119 | let mut id_map = HashMap::new(); 120 | let mut defs: Vec> = vec![]; 121 | for (i, def) in item_ron.defs.into_iter().enumerate() { 122 | name_map.insert(def.name.clone(), i); 123 | id_map.insert(def.id, i); 124 | defs.push(Arc::new(def)); 125 | } 126 | ItemLib { 127 | next_id: item_ron.next_id, 128 | name_map, 129 | id_map, 130 | defs, 131 | } 132 | } 133 | 134 | fn save(&self, path: &str) { 135 | let mut defs = vec![]; 136 | for def in self.defs.clone() { 137 | defs.push((*def).clone()); 138 | } 139 | let item_ron = ItemRon { 140 | next_id: self.next_id, 141 | defs, 142 | }; 143 | 144 | let mut item_def_file = OpenOptions::new() 145 | .truncate(true) 146 | .write(true) 147 | .create(true) 148 | .open(path) 149 | .unwrap(); 150 | let _ = item_def_file.write( 151 | ron::ser::to_string_pretty(&item_ron, ron::ser::PrettyConfig::default()) 152 | .unwrap() 153 | .as_bytes(), 154 | ); 155 | } 156 | } 157 | 158 | impl ItemLib { 159 | pub fn id(&self, id: u32) -> &ItemDef { 160 | &self.defs[self.id_map[&id]] 161 | } 162 | 163 | pub fn update_def(&mut self, def: Arc) { 164 | let id = &def.id.clone(); 165 | self.defs[self.id_map[id]] = def 166 | } 167 | 168 | pub fn name(&self, name: String) -> &Arc { 169 | &self.defs[self.name_map[&name]] 170 | } 171 | } 172 | 173 | #[cfg(test)] 174 | mod tests { 175 | use super::*; 176 | use crate::prelude::*; 177 | 178 | #[test] 179 | fn item_new() { 180 | let item_def = ItemDef { 181 | name: "Cheddar Cheese".to_string(), 182 | rules_text: "Cheesed to meet you".to_string(), 183 | flavor_text: "Something quite flavorful".to_string(), 184 | icon: "sprite/icon/cheese.png".to_string(), 185 | id: 1, 186 | item_type: ItemType::Consumable, 187 | item_rarity: ItemRarity::Common, 188 | max_stack: 50, 189 | buy_value: 10, 190 | sell_value: 5, 191 | equipment_def: Default::default(), 192 | }; 193 | let cheddar_cheese_def = Arc::new(item_def); 194 | let item_1 = Item { 195 | def: cheddar_cheese_def.clone(), 196 | text: String::new(), 197 | }; 198 | 199 | let item_2 = Item { 200 | def: cheddar_cheese_def.clone(), 201 | text: String::new(), 202 | }; 203 | 204 | assert_eq!(item_1.def.name, "Cheddar Cheese".to_string()); 205 | assert_eq!(item_2.def.name, "Cheddar Cheese".to_string()); 206 | } 207 | 208 | #[test] 209 | fn itemlib_load_and_access() { 210 | let item_lib = ItemLib::new(&format!("{}/test/data/test_item.ron", MECHANIC_TEST_DIR)); 211 | let expected_item_def = ItemDef { 212 | name: "Red Potion".to_string(), 213 | rules_text: "".to_string(), 214 | flavor_text: "A vibrant red potion. Probably safe to drink.".to_string(), 215 | icon: "sprite/icon/red_potion.png".to_string(), 216 | id: 0, 217 | item_type: ItemType::Miscellaneous, 218 | item_rarity: ItemRarity::Common, 219 | max_stack: 50, 220 | buy_value: 10, 221 | sell_value: 5, 222 | equipment_def: Default::default(), 223 | }; 224 | assert_eq!(*item_lib.id(0), expected_item_def); 225 | assert_eq!(item_lib.name("Red Potion".to_string()).name, "Red Potion"); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/game/mechanic/src/item/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod equipment; 2 | pub mod item; 3 | -------------------------------------------------------------------------------- /src/game/mechanic/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod aura; 2 | pub mod item; 3 | 4 | pub mod prelude { 5 | // Constants 6 | pub const MECHANIC_TEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); 7 | 8 | // Aura Modules 9 | pub use crate::aura::aura::*; 10 | 11 | // Item Modules 12 | pub use crate::item::item::*; 13 | } 14 | -------------------------------------------------------------------------------- /src/game/mechanic/test/data/test_aura.ron: -------------------------------------------------------------------------------- 1 | AuraRon ( 2 | next_id: 2, 3 | defs: [ 4 | AuraDef ( 5 | aura_type: None, 6 | icon: "sprite/icon/cheese.png", 7 | duration: 3600.0, 8 | id: 0, 9 | name: "Well Fed", 10 | rules_text: "You feel full! Your fortitudeness is through the roof." 11 | ), 12 | AuraDef ( 13 | aura_type: Magic, 14 | icon: "sprite/icon/lightning.png", 15 | duration: 10.0, 16 | id: 1, 17 | name: "Shocked", 18 | rules_text: "You've been shocked!" 19 | ) 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /src/game/mechanic/test/data/test_item.ron: -------------------------------------------------------------------------------- 1 | ItemRon ( 2 | next_id: 1, 3 | defs: [ 4 | ItemDef ( 5 | id: 0, 6 | name: "Red Potion", 7 | rules_text: "", 8 | flavor_text: "A vibrant red potion. Probably safe to drink.", 9 | icon: "sprite/icon/red_potion.png", 10 | item_type: Miscellaneous, 11 | item_rarity: Common, 12 | sell_value: 5, 13 | buy_value: 10, 14 | max_stack: 50, 15 | ), 16 | 17 | ItemDef ( 18 | id: 1, 19 | name: "Shoe", 20 | rules_text: "", 21 | flavor_text: "A super rad shoe. Unfortunately the second one is nowhere to be found.", 22 | icon: "sprite/icon/shoe.png", 23 | item_type: Equipment, 24 | item_rarity: Uncommon, 25 | sell_value: 100, 26 | buy_value: 200, 27 | max_stack: 1, 28 | equipment_def: EquipmentDef ( 29 | slot: Feet, 30 | armor: 5 31 | ) 32 | ) 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /src/game/system/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "game_system" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "lib"] 8 | 9 | [dependencies] 10 | bevy_reflect = { workspace = true } 11 | -------------------------------------------------------------------------------- /src/game/system/src/asset/asset_lib.rs: -------------------------------------------------------------------------------- 1 | use bevy_reflect::Reflect; 2 | use std::fmt; 3 | 4 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Reflect)] 5 | pub enum AssetType { 6 | Aura, 7 | Item, 8 | } 9 | 10 | impl fmt::Display for AssetType { 11 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 12 | write!(f, "{:?}", self) 13 | } 14 | } 15 | 16 | pub trait AssetLib { 17 | fn new(path: &str) -> Self; 18 | fn save(&self, path: &str); 19 | } 20 | -------------------------------------------------------------------------------- /src/game/system/src/asset/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod asset_lib; 2 | -------------------------------------------------------------------------------- /src/game/system/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod asset; 2 | 3 | pub mod prelude { 4 | // Asset Modules 5 | pub use crate::asset::asset_lib::*; 6 | } 7 | -------------------------------------------------------------------------------- /src/terminal/main.rs: -------------------------------------------------------------------------------- 1 | use term_screen::database::Database; 2 | use term_screen::menu::Menu; 3 | use term_system::tui; 4 | use term_system::window::{Screen, Window, WindowName}; 5 | 6 | use std::io; 7 | 8 | fn main() -> io::Result<()> { 9 | let mut terminal = tui::init()?; 10 | let mut current_window = WindowName::Menu; 11 | let mut menu = Menu::new(Window::default()); 12 | let mut database = Database::new(Window::default()); 13 | while current_window != WindowName::None { 14 | let window_result = match current_window { 15 | WindowName::Menu => menu.run(&mut terminal), 16 | WindowName::Database => database.run(&mut terminal), 17 | _ => Ok(WindowName::None), 18 | }; 19 | current_window = match window_result { 20 | Err(ref error) => { 21 | println!("Encountered an error: {:?}", error); 22 | WindowName::None 23 | } 24 | Ok(window) => window, 25 | }; 26 | } 27 | tui::restore()?; 28 | 29 | Ok(()) 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests {} 34 | -------------------------------------------------------------------------------- /src/terminal/screen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "term_screen" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | game_mechanic = { workspace = true } 8 | game_system = { workspace = true } 9 | bevy_reflect = { workspace = true } 10 | crossterm = { workspace = true } 11 | image = { workspace = true } 12 | ratatui = { workspace = true } 13 | term_system = { workspace = true } 14 | -------------------------------------------------------------------------------- /src/terminal/screen/src/database.rs: -------------------------------------------------------------------------------- 1 | use game_mechanic::item::equipment::EquipmentSlot; 2 | use game_mechanic::prelude::*; 3 | use game_system::asset::asset_lib::AssetLib; 4 | use game_system::prelude::AssetType; 5 | use image::DynamicImage; 6 | use ratatui::widgets::{ 7 | Block, Borders, List, ListDirection, ListItem, ListState, Paragraph, Scrollbar, 8 | ScrollbarOrientation, ScrollbarState, Wrap, 9 | }; 10 | 11 | use std::cmp::min; 12 | use std::fmt::Display; 13 | use std::io; 14 | use std::rc::Rc; 15 | use term_system::terminal_image::{load_image, set_background_color, UNKNOWN_IMAGE_PATH}; 16 | use term_system::window::{Screen, Window, WindowName}; 17 | use term_system::{terminal_image, tui}; 18 | 19 | use bevy_reflect::{GetPath, PartialReflect, Reflect, Struct}; 20 | use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 21 | use ratatui::prelude::*; 22 | 23 | const MAGIC_CURSOR_SYMBOL: &str = "ඞ"; 24 | 25 | #[derive(Clone, Reflect)] 26 | struct Asset { 27 | name: String, 28 | id: u32, 29 | asset_type: AssetType, 30 | icon: String, 31 | } 32 | 33 | struct AssetList { 34 | state: ListState, 35 | assets: Vec, 36 | } 37 | 38 | pub struct Database { 39 | window: Window, 40 | // All assets from each asset lib. 41 | assets: Vec, 42 | // All assets that match the current search. 43 | // What shows up in the assets frame 44 | visible_assets: AssetList, 45 | // Aura assets 46 | aura_lib: AuraLib, 47 | // Item assets 48 | item_lib: ItemLib, 49 | // The currently selected frame 50 | active_frame: DatabaseFrame, 51 | // How many lines have been scrolled in the details frame. 52 | details_scroll: u16, 53 | // How much you can scroll in the details frame. 54 | max_details_scroll: u16, 55 | // Visible text in the search frame. 56 | search_input: String, 57 | // Current character the cursor is at in the search input 58 | search_character_index: usize, 59 | // Visible text for the field being edited 60 | details_input: String, 61 | // Current character the cursor is at in the details input 62 | details_character_index: usize, 63 | // Which detail is currently selected 64 | details_index: usize, 65 | // Whether a detail is being edited 66 | editing_details: bool, 67 | // The asset currently visible in the details frame 68 | current_asset: Asset, 69 | // All of the field names belonging to the current asset 70 | current_asset_fields: Vec, 71 | // The global (x,y) position of the cursor. 72 | cursor_position: Position, 73 | } 74 | 75 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 76 | enum DatabaseFrame { 77 | Search, 78 | Assets, 79 | Details, 80 | } 81 | 82 | impl Screen for Database { 83 | fn new(window: Window) -> Self { 84 | // TODO: Make asset lib loading and asset rendering more generic. 85 | let aura_lib = AuraLib::new("asset/def/aura.ron"); 86 | let item_lib = ItemLib::new("asset/def/item.ron"); 87 | let mut assets: Vec = vec![]; 88 | for def in &aura_lib.defs { 89 | assets.push(Asset { 90 | name: def.name.clone(), 91 | id: def.id, 92 | asset_type: AssetType::Aura, 93 | icon: def.icon.clone(), 94 | }) 95 | } 96 | for def in &item_lib.defs { 97 | assets.push(Asset { 98 | name: def.name.clone(), 99 | id: def.id, 100 | asset_type: AssetType::Item, 101 | icon: def.icon.clone(), 102 | }) 103 | } 104 | let current_asset = assets[0].clone(); 105 | let num_assets = assets.len(); 106 | let visible_assets = AssetList::from_assets((0..num_assets).collect()); 107 | Self { 108 | window, 109 | aura_lib, 110 | item_lib, 111 | assets, 112 | visible_assets, 113 | active_frame: DatabaseFrame::Search, 114 | details_scroll: 0, 115 | max_details_scroll: 0, 116 | search_input: String::new(), 117 | search_character_index: 0, 118 | details_input: String::new(), 119 | details_character_index: 0, 120 | details_index: 0, 121 | editing_details: false, 122 | current_asset, 123 | current_asset_fields: vec![], 124 | cursor_position: Position { x: 1, y: 1 }, 125 | } 126 | } 127 | 128 | fn run(&mut self, terminal: &mut tui::Tui) -> io::Result { 129 | self.window.quit = false; 130 | self.window.change = false; 131 | self.window.draw_background = true; 132 | 133 | if self.visible_assets.state.selected().is_none() { 134 | self.visible_assets.state.select(Some(0)); 135 | } 136 | while !self.window.quit { 137 | let _ = terminal.draw(|frame| { 138 | if self.window.draw_background { 139 | set_background_color( 140 | frame.area(), 141 | frame.buffer_mut(), 142 | self.window.theme.black_dark, 143 | ); 144 | } else { 145 | self.window.draw_background = false 146 | } 147 | 148 | frame.render_widget(&mut *self, frame.area()); 149 | frame.set_cursor_position(self.cursor_position); 150 | }); 151 | let _ = self.handle_events(); 152 | } 153 | self.aura_lib.save("asset/def/aura.ron"); 154 | self.item_lib.save("asset/def/item.ron"); 155 | Ok(WindowName::Menu) 156 | } 157 | 158 | fn handle_events(&mut self) -> io::Result<()> { 159 | match event::read()? { 160 | Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { 161 | self.handle_key_event(key_event) 162 | } 163 | _ => {} 164 | }; 165 | Ok(()) 166 | } 167 | 168 | fn handle_key_event(&mut self, key_event: KeyEvent) { 169 | // Window wide hotkeys 170 | match key_event.code { 171 | KeyCode::Esc => self.window.quit = true, 172 | KeyCode::Tab => { 173 | self.active_frame = match self.active_frame { 174 | DatabaseFrame::Search => DatabaseFrame::Assets, 175 | DatabaseFrame::Assets => DatabaseFrame::Details, 176 | DatabaseFrame::Details => DatabaseFrame::Search, 177 | } 178 | } 179 | _ => {} 180 | } 181 | 182 | match self.active_frame { 183 | DatabaseFrame::Search => { 184 | self.editing_details = false; 185 | match key_event.code { 186 | KeyCode::Char(to_insert) => { 187 | self.search_input 188 | .insert(self.search_character_index, to_insert); 189 | self.search_character_index += 1; 190 | } 191 | KeyCode::Backspace => { 192 | if self.search_character_index > 0 { 193 | self.search_input 194 | .remove(self.search_character_index.saturating_sub(1)); 195 | self.search_character_index = 196 | self.search_character_index.saturating_sub(1); 197 | } 198 | } 199 | KeyCode::Left => { 200 | self.search_character_index = self.search_character_index.saturating_sub(1); 201 | } 202 | KeyCode::Right => { 203 | self.search_character_index = 204 | min(self.search_character_index + 1, self.search_input.len()) 205 | } 206 | KeyCode::Enter => { 207 | self.search_character_index = 0; 208 | self.search_input.clear(); 209 | } 210 | _ => {} 211 | }; 212 | self.cursor_position.y = 1; 213 | self.cursor_position.x = (self.search_character_index + 1) as u16; 214 | } 215 | DatabaseFrame::Assets => { 216 | self.details_scroll = 0; 217 | match key_event.code { 218 | KeyCode::Up => { 219 | self.details_index = 0; 220 | self.visible_assets.previous() 221 | } 222 | 223 | KeyCode::Down => { 224 | self.details_index = 0; 225 | self.visible_assets.next() 226 | } 227 | _ => {} 228 | }; 229 | } 230 | DatabaseFrame::Details => { 231 | if self.editing_details { 232 | match key_event.code { 233 | KeyCode::Char(to_insert) => { 234 | self.details_input 235 | .insert(self.details_character_index, to_insert); 236 | self.details_character_index += 1; 237 | } 238 | KeyCode::Backspace => { 239 | if self.details_character_index > 0 { 240 | self.details_input 241 | .remove(self.details_character_index.saturating_sub(1)); 242 | self.details_character_index = 243 | self.details_character_index.saturating_sub(1); 244 | } 245 | } 246 | KeyCode::Left => { 247 | self.details_character_index = 248 | self.details_character_index.saturating_sub(1); 249 | } 250 | KeyCode::Right => { 251 | self.details_character_index = 252 | min(self.details_character_index + 1, self.details_input.len()); 253 | } 254 | KeyCode::Enter => { 255 | match self.current_asset.asset_type { 256 | AssetType::Aura => { 257 | let mut aura = self.aura_lib.id(self.current_asset.id).clone(); 258 | set_field_value_from_string( 259 | &mut aura, 260 | &self.current_asset_fields[self.details_index], 261 | self.details_input.clone(), 262 | ); 263 | self.aura_lib.update_def(aura.into()); 264 | } 265 | AssetType::Item => { 266 | let mut item = self.item_lib.id(self.current_asset.id).clone(); 267 | set_field_value_from_string( 268 | &mut item, 269 | &self.current_asset_fields[self.details_index], 270 | self.details_input.clone(), 271 | ); 272 | self.item_lib.update_def(item.into()); 273 | } 274 | }; 275 | self.details_character_index = 0; 276 | self.details_input.clear(); 277 | self.editing_details = false; 278 | } 279 | _ => {} 280 | }; 281 | } else { 282 | match key_event.code { 283 | KeyCode::Up => match key_event.modifiers { 284 | KeyModifiers::SHIFT => { 285 | self.details_index = self.details_index.saturating_sub(1); 286 | } 287 | _ => { 288 | self.details_scroll = self.details_scroll.saturating_sub(1); 289 | } 290 | }, 291 | KeyCode::Down => match key_event.modifiers { 292 | KeyModifiers::SHIFT => { 293 | self.details_index = min( 294 | self.details_index.saturating_add(1), 295 | self.current_asset_fields.len() - 1, 296 | ); 297 | } 298 | _ => { 299 | self.details_scroll = min( 300 | self.details_scroll.saturating_add(1), 301 | self.max_details_scroll, 302 | ); 303 | } 304 | }, 305 | KeyCode::Enter => { 306 | self.editing_details = true; 307 | self.details_input = match self.current_asset.asset_type { 308 | AssetType::Aura => get_string_value_from_path( 309 | self.aura_lib.id(self.current_asset.id), 310 | &self.current_asset_fields[self.details_index], 311 | ), 312 | AssetType::Item => get_string_value_from_path( 313 | self.item_lib.id(self.current_asset.id), 314 | &self.current_asset_fields[self.details_index], 315 | ), 316 | }; 317 | self.details_character_index = self.details_input.len(); 318 | } 319 | _ => {} 320 | } 321 | } 322 | } 323 | } 324 | } 325 | } 326 | 327 | impl Widget for &mut Database { 328 | fn render(self, area: Rect, buf: &mut Buffer) { 329 | let horizontal_sections = Layout::default() 330 | .direction(Direction::Vertical) 331 | .constraints(vec![ 332 | Constraint::Length(3), 333 | Constraint::Length(area.height - 3), 334 | ]) 335 | .split(area); 336 | let vertical_sections = Layout::default() 337 | .direction(Direction::Horizontal) 338 | .constraints(vec![ 339 | Constraint::Length(20), 340 | Constraint::Length(area.width - 20), 341 | ]) 342 | .split(horizontal_sections[1]); 343 | self.render_search_bar(horizontal_sections[0], buf); 344 | self.render_assets(vertical_sections[0], buf); 345 | self.get_cursor_position(vertical_sections[1], buf); 346 | self.render_details(vertical_sections[1], buf, false); 347 | } 348 | } 349 | 350 | impl Database { 351 | fn get_title_style(&self, frame: DatabaseFrame) -> Style { 352 | if self.active_frame == frame { 353 | Style::default().fg(self.window.theme.green) 354 | } else { 355 | Style::default().fg(self.window.theme.white) 356 | } 357 | } 358 | 359 | fn render_assets(&mut self, area: Rect, buf: &mut Buffer) { 360 | // TODO: Add Markers for asset types. ◆ ■ ○ ● 361 | self.populate_visible_assets(); 362 | let mut list_items = vec![]; 363 | for index in &self.visible_assets.assets { 364 | list_items.push(self.assets[*index].to_list_item()) 365 | } 366 | 367 | let asset_list = List::new(list_items) 368 | .block( 369 | Block::default() 370 | .title("Assets") 371 | .borders(Borders::ALL) 372 | .border_type(self.window.border_type) 373 | .style(Style::default().fg(self.window.theme.white)) 374 | .title_style(self.get_title_style(DatabaseFrame::Assets)), 375 | ) 376 | .bg(self.window.theme.black_dark) 377 | .fg(self.window.theme.white) 378 | .highlight_style(Style::default().fg(self.window.theme.red)) 379 | .direction(ListDirection::TopToBottom); 380 | 381 | StatefulWidget::render(&asset_list, area, buf, &mut self.visible_assets.state); 382 | 383 | if asset_list.len() > 0 { 384 | let scroll = asset_list.len().saturating_sub((area.height - 1) as usize); 385 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 386 | .track_symbol(Some(self.window.border_type.to_border_set().vertical_left)) 387 | .begin_symbol(Some("▲")) 388 | .end_symbol(Some("▼")); 389 | 390 | let mut scrollbar_state = 391 | ScrollbarState::new(scroll).position(self.visible_assets.state.selected().unwrap()); 392 | 393 | StatefulWidget::render( 394 | scrollbar, 395 | area.inner(Margin { 396 | vertical: 1, 397 | horizontal: 0, 398 | }), 399 | buf, 400 | &mut scrollbar_state, 401 | ); 402 | } 403 | } 404 | 405 | fn render_details(&mut self, area: Rect, buf: &mut Buffer, render_cursor: bool) { 406 | if self.visible_assets.assets.is_empty() { 407 | self.render_empty_details(area, buf); 408 | return; 409 | } 410 | let asset = self.assets 411 | [self.visible_assets.assets[self.visible_assets.state.selected().unwrap()]] 412 | .clone(); 413 | let img = self.get_icon(&asset); 414 | let icon_width = min(area.width / 4, img.width() as u16); 415 | let sections = self.build_details_sections(area, icon_width); 416 | 417 | self.current_asset = asset.clone(); 418 | 419 | let full_details = match asset.asset_type { 420 | AssetType::Aura => { 421 | self.current_asset_fields = get_def_paths(self.aura_lib.id(asset.id)); 422 | self.add_aura_details(&asset, render_cursor) 423 | } 424 | AssetType::Item => { 425 | self.current_asset_fields = get_def_paths(self.item_lib.id(asset.id)); 426 | self.add_item_details(&asset, render_cursor) 427 | } 428 | }; 429 | 430 | let p = self.build_details_paragraph(full_details); 431 | 432 | let max_details_scroll = 433 | (p.line_count(sections[0].width) as u16).saturating_sub(area.height - 1); 434 | p.clone() 435 | .scroll((min(self.details_scroll, max_details_scroll), 0)) 436 | .render(sections[0], buf); 437 | 438 | self.max_details_scroll = max_details_scroll; 439 | 440 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 441 | .track_symbol(Some(self.window.border_type.to_border_set().vertical_left)) 442 | .begin_symbol(Some("▲")) 443 | .end_symbol(Some("▼")); 444 | 445 | let mut scrollbar_state = 446 | ScrollbarState::new(max_details_scroll.into()).position(self.details_scroll.into()); 447 | 448 | StatefulWidget::render( 449 | scrollbar, 450 | sections[0].inner(Margin { 451 | vertical: 1, 452 | horizontal: 0, 453 | }), 454 | buf, 455 | &mut scrollbar_state, 456 | ); 457 | 458 | terminal_image::render_image_with_border( 459 | sections[1], 460 | buf, 461 | img, 462 | self.window.theme, 463 | self.window.border_type, 464 | ); 465 | } 466 | 467 | fn build_details_sections(&mut self, area: Rect, icon_width: u16) -> Rc<[Rect]> { 468 | Layout::default() 469 | .direction(Direction::Horizontal) 470 | .constraints(vec![ 471 | Constraint::Length(area.width - icon_width), 472 | Constraint::Length(icon_width), 473 | ]) 474 | .split(area) 475 | } 476 | 477 | fn build_details_paragraph<'a>(&self, contents: Vec>) -> Paragraph<'a> { 478 | Paragraph::new(contents) 479 | .block( 480 | Block::default() 481 | .title("Details") 482 | .borders(Borders::ALL) 483 | .border_type(self.window.border_type) 484 | .style(Style::default().fg(self.window.theme.white)) 485 | .title_style(self.get_title_style(DatabaseFrame::Details)), 486 | ) 487 | .bg(self.window.theme.black_dark) 488 | .fg(self.window.theme.white) 489 | .wrap(Wrap { trim: true }) 490 | } 491 | 492 | fn render_empty_details(&mut self, area: Rect, buf: &mut Buffer) { 493 | let sections = self.build_details_sections(area, min(area.width / 5, 32)); 494 | let p = self.build_details_paragraph(vec![]); 495 | p.render(sections[0], buf); 496 | terminal_image::render_image_path_with_border( 497 | sections[1], 498 | buf, 499 | UNKNOWN_IMAGE_PATH, 500 | self.window.theme, 501 | self.window.border_type, 502 | ); 503 | } 504 | 505 | fn get_icon(&self, asset: &Asset) -> DynamicImage { 506 | let path = match asset.asset_type { 507 | AssetType::Aura => self.aura_lib.id(asset.id).icon.clone(), 508 | AssetType::Item => self.item_lib.id(asset.id).icon.clone(), 509 | }; 510 | load_image(&format!("asset/{}", &path)) 511 | } 512 | 513 | fn add_aura_details(&self, asset: &Asset, with_cursor_marker: bool) -> Vec { 514 | let mut details = vec![]; 515 | let aura = self.aura_lib.id(asset.id); 516 | for path in &self.current_asset_fields { 517 | details.push(self.format_detail( 518 | path, 519 | &get_string_value_from_path(aura, path), 520 | with_cursor_marker, 521 | )); 522 | } 523 | details 524 | } 525 | 526 | fn add_item_details(&self, asset: &Asset, with_cursor_marker: bool) -> Vec { 527 | let mut details = vec![]; 528 | let item = self.item_lib.id(asset.id); 529 | for path in &self.current_asset_fields { 530 | details.push(self.format_detail( 531 | path, 532 | &get_string_value_from_path(item, path), 533 | with_cursor_marker, 534 | )); 535 | } 536 | details 537 | } 538 | 539 | fn format_detail( 540 | &self, 541 | path: &str, 542 | contents: &T, 543 | with_cursor_marker: bool, 544 | ) -> Line { 545 | // This is a bit hacky and probably has issues. If it parses to an f64 546 | // then assume its a number and color it red. 547 | let contents_color = if contents.to_string().parse::().is_ok() { 548 | self.window.theme.red 549 | } else { 550 | self.window.theme.white 551 | }; 552 | 553 | let mut contents = contents.to_string(); 554 | let field_color = if self.current_asset_fields[self.details_index] == path { 555 | if self.editing_details { 556 | contents = self.details_input.clone(); 557 | if with_cursor_marker { 558 | contents.replace_range( 559 | self.details_character_index.saturating_sub(1) 560 | ..self.details_character_index, 561 | MAGIC_CURSOR_SYMBOL, 562 | ); 563 | } 564 | self.window.theme.green 565 | } else { 566 | self.window.theme.red 567 | } 568 | } else { 569 | self.window.theme.blue 570 | }; 571 | 572 | vec![ 573 | Span::styled(format!("{}: ", path), Style::default().fg(field_color)), 574 | Span::styled(contents.to_string(), Style::default().fg(contents_color)), 575 | ] 576 | .into() 577 | } 578 | 579 | fn render_search_bar(&self, area: Rect, buf: &mut Buffer) { 580 | Paragraph::new(self.search_input.to_string()) 581 | .block( 582 | Block::default() 583 | .title("Search") 584 | .borders(Borders::ALL) 585 | .border_type(self.window.border_type) 586 | .style(Style::default().fg(self.window.theme.white)) 587 | .title_style(self.get_title_style(DatabaseFrame::Search)), 588 | ) 589 | .bg(self.window.theme.black_dark) 590 | .fg(self.window.theme.white) 591 | .render(area, buf); 592 | } 593 | 594 | fn populate_visible_assets(&mut self) { 595 | self.visible_assets.assets = vec![]; 596 | for i in 0..self.assets.len() { 597 | // TODO: Implement a more sophisticated search 598 | if self.assets[i] 599 | .name 600 | .to_lowercase() 601 | .starts_with(&self.search_input.to_lowercase()) 602 | { 603 | self.visible_assets.assets.push(i) 604 | } 605 | } 606 | self.visible_assets.clamp(); 607 | } 608 | 609 | // This is a horrible, terrible hack. We render the entire details frame twice 610 | // to a duplicate buffer with a special character, and then iterate through 611 | // the entire frame until we find the absolute position of that special 612 | // character, and use that as our cursor location. 613 | // 614 | // TODO: Fix incorrect cursor behaviour that occurs when deleting characters 615 | // causes the text to unwrap. 616 | // TODO: Get rid of this entirely once Ratatui has better support for getting 617 | // cursor position from wrapped text. 618 | fn get_cursor_position(&mut self, area: Rect, buf: &mut Buffer) { 619 | let mut fake_buf = buf.clone(); 620 | self.render_details(area, &mut fake_buf, true); 621 | for x in 0..area.width { 622 | for y in 0..area.height { 623 | if let Some(character) = fake_buf.cell(Position { x, y }) { 624 | if character.symbol() == MAGIC_CURSOR_SYMBOL { 625 | if self.details_character_index == 0 { 626 | self.cursor_position = Position { x, y }; 627 | } else { 628 | self.cursor_position = Position { x: x + 1, y }; 629 | } 630 | 631 | break; 632 | } 633 | } 634 | } 635 | } 636 | } 637 | } 638 | 639 | fn get_def_paths_helper(def: &dyn Struct, current_path: &str, paths: &mut Vec) { 640 | for (i, field) in def.iter_fields().enumerate() { 641 | match field.reflect_ref().as_struct() { 642 | Err(_) => { 643 | if current_path.is_empty() { 644 | paths.push(def.name_at(i).unwrap().to_string()); 645 | } else { 646 | paths.push(format!("{}.{}", current_path, def.name_at(i).unwrap())); 647 | } 648 | } 649 | Ok(sub_field) => get_def_paths_helper(sub_field, def.name_at(i).unwrap(), paths), 650 | } 651 | } 652 | } 653 | 654 | fn get_def_paths(def: &dyn Struct) -> Vec { 655 | let mut field_paths: Vec = vec![]; 656 | get_def_paths_helper(def, "", &mut field_paths); 657 | field_paths 658 | } 659 | 660 | fn get_string_value_from_path(def: &T, path: &str) -> String { 661 | // Numeric Types 662 | if let Ok(value) = def.path::(path) { 663 | return (*value).to_string(); 664 | } else if let Ok(value) = def.path::(path) { 665 | return (*value).to_string(); 666 | } else if let Ok(value) = def.path::(path) { 667 | return (*value).to_string(); 668 | } else if let Ok(value) = def.path::(path) { 669 | return (*value).to_string(); 670 | } else if let Ok(value) = def.path::(path) { 671 | return (*value).to_string(); 672 | } else if let Ok(value) = def.path::(path) { 673 | return (*value).to_string(); 674 | } else if let Ok(value) = def.path::(path) { 675 | return (*value).to_string(); 676 | 677 | // String 678 | } else if let Ok(value) = def.path::(path) { 679 | return (*value).to_string(); 680 | } else if let Ok(value) = def.path::<&str>(path) { 681 | return (*value).to_string(); 682 | 683 | // Enums 684 | // TODO: Find some way to handle enums more generally. 685 | } else if let Ok(value) = def.path::(path) { 686 | return (*value).to_string(); 687 | } else if let Ok(value) = def.path::(path) { 688 | return (*value).to_string(); 689 | } else if let Ok(value) = def.path::(path) { 690 | return (*value).to_string(); 691 | } else if let Ok(value) = def.path::(path) { 692 | return (*value).to_string(); 693 | } 694 | 695 | "UNKNOWN_TYPE".to_string() 696 | } 697 | 698 | fn set_field_value_from_string( 699 | def: &mut T, 700 | path: &str, 701 | new_value: String, 702 | ) { 703 | // TODO: Don't panic when failing to parse. Come up with a way 704 | // to indicate to the user that a failure occurred. 705 | // Numeric Types 706 | if let Ok(value) = def.path_mut::(path) { 707 | *value = new_value.parse::().unwrap(); 708 | } else if let Ok(value) = def.path_mut::(path) { 709 | *value = new_value.parse::().unwrap(); 710 | } else if let Ok(value) = def.path_mut::(path) { 711 | *value = new_value.parse::().unwrap(); 712 | } else if let Ok(value) = def.path_mut::(path) { 713 | *value = new_value.parse::().unwrap(); 714 | } else if let Ok(value) = def.path_mut::(path) { 715 | *value = new_value.parse::().unwrap(); 716 | } else if let Ok(value) = def.path_mut::(path) { 717 | *value = new_value.parse::().unwrap(); 718 | 719 | // String 720 | } else if let Ok(value) = def.path_mut::(path) { 721 | *value = new_value 722 | 723 | // Enums 724 | } else if let Ok(value) = def.path_mut::(path) { 725 | *value = new_value.parse::().unwrap(); 726 | } else if let Ok(value) = def.path_mut::(path) { 727 | *value = new_value.parse::().unwrap(); 728 | } else if let Ok(value) = def.path_mut::(path) { 729 | *value = new_value.parse::().unwrap(); 730 | } else if let Ok(value) = def.path_mut::(path) { 731 | *value = new_value.parse::().unwrap(); 732 | } 733 | } 734 | 735 | impl AssetList { 736 | fn from_assets(assets: Vec) -> AssetList { 737 | AssetList { 738 | state: ListState::default(), 739 | assets, 740 | } 741 | } 742 | 743 | fn next(&mut self) { 744 | if self.state.selected().unwrap() < self.assets.len() - 1 { 745 | self.state.select(Some(self.state.selected().unwrap() + 1)); 746 | } 747 | } 748 | 749 | fn previous(&mut self) { 750 | if self.state.selected().unwrap() > 0 { 751 | self.state.select(Some(self.state.selected().unwrap() - 1)); 752 | } 753 | } 754 | 755 | fn clamp(&mut self) { 756 | if self.state.selected().is_some() { 757 | if self.state.selected().unwrap() >= self.assets.len() { 758 | self.state.select(Some(self.assets.len().saturating_sub(1))); 759 | } 760 | } else { 761 | self.state.select(Some(0)); 762 | } 763 | } 764 | } 765 | 766 | impl Asset { 767 | fn to_list_item(&self) -> ListItem { 768 | ListItem::new(self.name.to_string()) 769 | } 770 | } 771 | -------------------------------------------------------------------------------- /src/terminal/screen/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod database; 2 | pub mod menu; 3 | -------------------------------------------------------------------------------- /src/terminal/screen/src/menu.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use term_system::terminal_image::set_background_color; 3 | use term_system::theme::Theme; 4 | use term_system::tui; 5 | use term_system::window::{Screen, Window, WindowName}; 6 | 7 | use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; 8 | use ratatui::{ 9 | prelude::*, 10 | widgets::{block::*, *}, 11 | }; 12 | 13 | struct MenuOption<'a> { 14 | menu_option: &'a WindowName, 15 | } 16 | 17 | struct MenuOptionList<'a> { 18 | state: ListState, 19 | menu_options: Vec>, 20 | } 21 | 22 | pub struct Menu<'a> { 23 | window: Window, 24 | menu_options: MenuOptionList<'a>, 25 | } 26 | 27 | impl Screen for Menu<'_> { 28 | fn new(window: Window) -> Self { 29 | Self { 30 | window, 31 | // NOTE: You can add more windows here to add extra options 32 | menu_options: MenuOptionList::with_menu_options(vec![&WindowName::Database]), 33 | } 34 | } 35 | 36 | fn run(&mut self, terminal: &mut tui::Tui) -> io::Result { 37 | self.window.quit = false; 38 | self.window.change = false; 39 | self.window.draw_background = true; 40 | 41 | if self.menu_options.state.selected().is_none() { 42 | self.menu_options.state.select(Some(0)); 43 | } 44 | 45 | while !self.window.quit && !self.window.change { 46 | let _ = terminal.draw(|frame| { 47 | if self.window.draw_background { 48 | set_background_color( 49 | frame.area(), 50 | frame.buffer_mut(), 51 | self.window.theme.black_dark, 52 | ); 53 | } else { 54 | self.window.draw_background = false 55 | } 56 | frame.render_widget(&mut *self, frame.area()); 57 | }); 58 | let _ = self.handle_events(); 59 | } 60 | 61 | if self.window.change { 62 | return Ok(*self.menu_options.menu_options 63 | [self.menu_options.state.selected().unwrap()] 64 | .menu_option); 65 | } 66 | Ok(WindowName::None) 67 | } 68 | 69 | fn handle_events(&mut self) -> io::Result<()> { 70 | match event::read()? { 71 | Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { 72 | self.handle_key_event(key_event) 73 | } 74 | _ => {} 75 | }; 76 | Ok(()) 77 | } 78 | 79 | fn handle_key_event(&mut self, key_event: KeyEvent) { 80 | match key_event.code { 81 | KeyCode::Esc => self.window.quit = true, 82 | KeyCode::Enter => self.window.change = true, 83 | KeyCode::Up => self.menu_options.previous(), 84 | KeyCode::Down => self.menu_options.next(), 85 | _ => {} 86 | } 87 | } 88 | } 89 | 90 | impl Widget for &mut Menu<'_> { 91 | fn render(self, area: Rect, buf: &mut Buffer) { 92 | let half_width = area.width / 2; 93 | let half_height = area.height / 2; 94 | let horizontal_sections = Layout::default() 95 | .direction(Direction::Vertical) 96 | .constraints(vec![ 97 | Constraint::Length(half_height - 1), 98 | // Options box height 99 | Constraint::Length(3), 100 | Constraint::Length(half_height - 2), 101 | ]) 102 | .split(area); 103 | 104 | let title_layout = Layout::default() 105 | .direction(Direction::Horizontal) 106 | .constraints(vec![ 107 | Constraint::Length(half_width.saturating_sub(31)), 108 | // Title width 109 | Constraint::Length(62), 110 | Constraint::Length(half_width.saturating_sub(31)), 111 | ]) 112 | .split(horizontal_sections[0]); 113 | 114 | let options_layout = Layout::default() 115 | .direction(Direction::Horizontal) 116 | .constraints(vec![ 117 | Constraint::Length(half_width.saturating_sub(10)), 118 | // Options box width 119 | Constraint::Length(20), 120 | Constraint::Length(half_width.saturating_sub(10)), 121 | ]) 122 | .split(horizontal_sections[1]); 123 | 124 | render_title(title_layout[1], buf, self.window); 125 | render_menu(options_layout[1], buf, &mut self.menu_options, self.window); 126 | } 127 | } 128 | 129 | fn render_title(area: Rect, buf: &mut Buffer, window: Window) { 130 | let title = " 131 | ███████╗██████╗ ██╗████████╗ ██████╗ ██████╗ 132 | ██╔════╝██╔══██╗██║╚══██╔══╝██╔═══██╗██╔══██╗ 133 | █████╗ ██║ ██║██║ ██║ ██║ ██║██████╔╝ 134 | ██╔══╝ ██║ ██║██║ ██║ ██║ ██║██╔══██╗ 135 | ███████╗██████╔╝██║ ██║ ╚██████╔╝██║ ██║ 136 | ╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ 137 | █████████████████████████████████████████████████████████████╗ 138 | ╚════════════════════════════════════════════════════════════╝ 139 | "; 140 | 141 | // Spacing between title and top of terminal 142 | let mut constraints = vec![Constraint::Length(2)]; 143 | 144 | let title_lines = title.split("\n").collect::>(); 145 | for _ in 1..title_lines.len() { 146 | constraints.push(Constraint::Length(1)); 147 | } 148 | let title_layout = Layout::default() 149 | .direction(Direction::Vertical) 150 | .constraints(constraints) 151 | .split(area); 152 | 153 | // Render the title using a f-a-n-c-y gradient 154 | for i in 0..title_lines.len() { 155 | Paragraph::new(title_lines[i]) 156 | .block(Block::new()) 157 | .bg(window.theme.black_dark) 158 | .fg(Theme::lerp( 159 | window.theme.white, 160 | window.theme.white_dark, 161 | 1.0 - (i as f32) / (title_lines.len() as f32), 162 | )) 163 | .render(title_layout[i], buf); 164 | } 165 | } 166 | 167 | fn render_menu(area: Rect, buf: &mut Buffer, menu_options: &mut MenuOptionList, window: Window) { 168 | let list_items: Vec = menu_options 169 | .menu_options 170 | .iter() 171 | .map(|menu_option| menu_option.to_list_item()) 172 | .collect(); 173 | 174 | let list = List::new(list_items) 175 | .block( 176 | Block::default() 177 | .borders(Borders::ALL) 178 | .border_type(window.border_type) 179 | .style(Style::default().fg(window.theme.white)), 180 | ) 181 | .style(Style::default().fg(window.theme.white)) 182 | .highlight_style( 183 | Style::default() 184 | .fg(window.theme.black_dark) 185 | .bg(window.theme.white), 186 | ) 187 | .direction(ListDirection::TopToBottom); 188 | StatefulWidget::render(list, area, buf, &mut menu_options.state); 189 | } 190 | 191 | impl MenuOptionList<'_> { 192 | fn with_menu_options(menu_options: Vec<&WindowName>) -> MenuOptionList { 193 | MenuOptionList { 194 | state: ListState::default(), 195 | menu_options: menu_options.iter().map(MenuOption::from).collect(), 196 | } 197 | } 198 | 199 | fn next(&mut self) { 200 | if self.state.selected().unwrap() < self.menu_options.len() - 1 { 201 | self.state.select(Some(self.state.selected().unwrap() + 1)); 202 | } 203 | } 204 | 205 | fn previous(&mut self) { 206 | if self.state.selected().unwrap() > 0 { 207 | self.state.select(Some(self.state.selected().unwrap() - 1)); 208 | } 209 | } 210 | } 211 | 212 | impl<'a> From<&&'a WindowName> for MenuOption<'a> { 213 | fn from(menu_option: &&'a WindowName) -> Self { 214 | Self { menu_option } 215 | } 216 | } 217 | 218 | impl MenuOption<'_> { 219 | fn to_list_item(&self) -> ListItem { 220 | ListItem::new(self.menu_option.to_string()) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/terminal/system/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "term_system" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | crossterm = { workspace = true } 8 | ratatui = { workspace = true } 9 | image = { workspace = true } 10 | -------------------------------------------------------------------------------- /src/terminal/system/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod terminal_image; 2 | pub mod theme; 3 | pub mod tui; 4 | pub mod window; 5 | -------------------------------------------------------------------------------- /src/terminal/system/src/terminal_image.rs: -------------------------------------------------------------------------------- 1 | use crate::theme::Theme; 2 | use image::{imageops::FilterType::Nearest, DynamicImage, GenericImageView}; 3 | 4 | use ratatui::{prelude::*, widgets::BorderType}; 5 | 6 | pub const UNKNOWN_IMAGE_PATH: &str = "asset/sprite/icon/unknown.png"; 7 | 8 | // Renders an image to the screen. Two pixels exist in a single space, where the ▀ 9 | // character represents the top pixel and the background color represents the bottom pixel 10 | fn render(pos_x: u16, pos_y: u16, buf: &mut Buffer, img: DynamicImage, theme: Theme) { 11 | for x in 0..img.width() { 12 | for y in (0..img.height() - 1).step_by(2) { 13 | let fg_p = img.get_pixel(x, y); 14 | let fg_color = Color::Rgb(fg_p[0], fg_p[1], fg_p[2]); 15 | let mut bg_color = theme.black_dark; 16 | if y < img.width() { 17 | let bg_p = img.get_pixel(x, y + 1); 18 | bg_color = Color::Rgb(bg_p[0], bg_p[1], bg_p[2]); 19 | } 20 | buf.cell_mut(Position { 21 | x: (x as u16) + pos_x, 22 | y: ((y / 2) as u16) + pos_y, 23 | }) 24 | .unwrap() 25 | .set_char('▀') 26 | .set_fg(fg_color) 27 | .set_bg(bg_color); 28 | } 29 | } 30 | } 31 | 32 | // Renders an image and draws a border around it. 33 | pub fn render_image_with_border( 34 | area: Rect, 35 | buf: &mut Buffer, 36 | img: DynamicImage, 37 | theme: Theme, 38 | border: BorderType, 39 | ) { 40 | let pos_x = area.left(); 41 | let pos_y = area.top(); 42 | // The area inside the border 43 | let image_area = Rect { 44 | x: pos_x + 1, 45 | y: pos_y + 1, 46 | width: area.width - 2, 47 | height: area.height - 2, 48 | }; 49 | let img = resize_image(image_area, img); 50 | 51 | let end_x = pos_x + (img.width() as u16) + 1; 52 | let end_y = pos_y + ((img.height() / 2) as u16) + 1; 53 | render(pos_x + 1, pos_y + 1, buf, img.clone(), theme); 54 | 55 | let bs = border.to_border_set(); 56 | 57 | // Get the individual ascii characters that constitute the border 58 | let horizontal_border = bs.horizontal_top.chars().next().unwrap(); 59 | let vertical_border = bs.vertical_left.chars().next().unwrap(); 60 | let top_left_border = bs.top_left.chars().next().unwrap(); 61 | let top_right_border = bs.top_right.chars().next().unwrap(); 62 | let bottom_right_border = bs.bottom_right.chars().next().unwrap(); 63 | let bottom_left_border = bs.bottom_left.chars().next().unwrap(); 64 | 65 | // Draw the top border 66 | for x in pos_x..end_x { 67 | buf.cell_mut(Position { x, y: pos_y }) 68 | .unwrap() 69 | .set_char(horizontal_border) 70 | .set_fg(theme.white) 71 | .set_bg(theme.black_dark); 72 | } 73 | 74 | // Draw the vertical borders 75 | for y in pos_y..end_y { 76 | buf.cell_mut(Position { x: pos_x, y }) 77 | .unwrap() 78 | .set_char(vertical_border) 79 | .set_fg(theme.white) 80 | .set_bg(theme.black_dark); 81 | buf.cell_mut(Position { x: end_x, y }) 82 | .unwrap() 83 | .set_char(vertical_border) 84 | .set_fg(theme.white) 85 | .set_bg(theme.black_dark); 86 | } 87 | 88 | // Draw the bottom border 89 | for x in pos_x..end_x { 90 | buf.cell_mut(Position { x, y: end_y }) 91 | .unwrap() 92 | .set_char(horizontal_border) 93 | .set_fg(theme.white) 94 | .set_bg(theme.black_dark); 95 | } 96 | 97 | // Draw the corners 98 | buf.cell_mut(Position { x: pos_x, y: pos_y }) 99 | .unwrap() 100 | .set_char(top_left_border) 101 | .set_fg(theme.white) 102 | .set_bg(theme.black_dark); 103 | 104 | buf.cell_mut(Position { x: end_x, y: pos_y }) 105 | .unwrap() 106 | .set_char(top_right_border) 107 | .set_fg(theme.white) 108 | .set_bg(theme.black_dark); 109 | 110 | buf.cell_mut(Position { x: pos_x, y: end_y }) 111 | .unwrap() 112 | .set_char(bottom_left_border) 113 | .set_fg(theme.white) 114 | .set_bg(theme.black_dark); 115 | 116 | buf.cell_mut(Position { x: end_x, y: end_y }) 117 | .unwrap() 118 | .set_char(bottom_right_border) 119 | .set_fg(theme.white) 120 | .set_bg(theme.black_dark); 121 | } 122 | 123 | fn resize_image(area: Rect, img: DynamicImage) -> DynamicImage { 124 | let area_width = area.width as u32; 125 | let area_height = (area.height * 2) as u32; 126 | 127 | // Since each space is two pixels tall, we mutliply height by 2 128 | // to avoid stretching the image. 129 | if img.width() > area_width && img.height() > area_height { 130 | return img.resize(area_width, area_height, Nearest); 131 | } 132 | if img.width() > area_width { 133 | return img.resize(area_width, img.height(), Nearest); 134 | } 135 | if img.height() > area_height { 136 | return img.resize(img.width(), area_height, Nearest); 137 | } 138 | img 139 | } 140 | 141 | pub fn render_image(area: Rect, buf: &mut Buffer, img: DynamicImage, theme: Theme) { 142 | let img = resize_image(area, img); 143 | render(area.left(), area.top(), buf, img, theme); 144 | } 145 | 146 | pub fn render_image_path(area: Rect, buf: &mut Buffer, image_path: &str, theme: Theme) { 147 | let mut img = image::open(image_path).unwrap_or(image::open(UNKNOWN_IMAGE_PATH).unwrap()); 148 | img = resize_image(area, img); 149 | render(area.left(), area.top(), buf, img, theme); 150 | } 151 | 152 | pub fn render_image_path_with_border( 153 | area: Rect, 154 | buf: &mut Buffer, 155 | image_path: &str, 156 | theme: Theme, 157 | border: BorderType, 158 | ) { 159 | let img = load_image(image_path); 160 | render_image_with_border(area, buf, img, theme, border); 161 | } 162 | 163 | pub fn load_image(image_path: &str) -> DynamicImage { 164 | image::open(image_path).unwrap_or(image::open(UNKNOWN_IMAGE_PATH).unwrap()) 165 | } 166 | 167 | // Overwrites the background color of an entire area to the `color` 168 | pub fn set_background_color(area: Rect, buf: &mut Buffer, color: Color) { 169 | for x in 0..area.width { 170 | for y in 0..area.height { 171 | buf.cell_mut(Position { x, y }).unwrap().set_bg(color); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/terminal/system/src/theme.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | 3 | // A ba 4 | #[derive(Debug, Copy, Clone)] 5 | pub struct Theme { 6 | pub white_light: Color, 7 | pub black_light: Color, 8 | pub red_light: Color, 9 | pub green_light: Color, 10 | pub blue_light: Color, 11 | pub cyan_light: Color, 12 | pub yellow_light: Color, 13 | pub magenta_light: Color, 14 | 15 | pub white: Color, 16 | pub black: Color, 17 | pub red: Color, 18 | pub green: Color, 19 | pub blue: Color, 20 | pub cyan: Color, 21 | pub yellow: Color, 22 | pub magenta: Color, 23 | 24 | pub white_dark: Color, 25 | pub black_dark: Color, 26 | pub red_dark: Color, 27 | pub green_dark: Color, 28 | pub blue_dark: Color, 29 | pub cyan_dark: Color, 30 | pub yellow_dark: Color, 31 | pub magenta_dark: Color, 32 | } 33 | 34 | impl Theme { 35 | pub const AMBER: Theme = Theme { 36 | white_light: Color::from_u32(0xfca21b), 37 | black_light: Color::from_u32(0x282423), 38 | red_light: Color::from_u32(0xf75b40), 39 | green_light: Color::from_u32(0xB5B21B), 40 | blue_light: Color::from_u32(0x6082A3), 41 | cyan_light: Color::from_u32(0x68c4c2), 42 | yellow_light: Color::from_u32(0xefc807), 43 | magenta_light: Color::from_u32(0x7155d6), 44 | 45 | white: Color::from_u32(0xF8881D), 46 | black: Color::from_u32(0x1B1817), 47 | red: Color::from_u32(0xDE4227), 48 | green: Color::from_u32(0x949216), 49 | blue: Color::from_u32(0x4F6A83), 50 | cyan: Color::from_u32(0x5d9996), 51 | yellow: Color::from_u32(0xba9c0b), 52 | magenta: Color::from_u32(0x544689), 53 | 54 | white_dark: Color::from_u32(0xa03500), 55 | black_dark: Color::from_u32(0x130E0E), 56 | red_dark: Color::from_u32(0xA71800), 57 | green_dark: Color::from_u32(0x5a5b01), 58 | blue_dark: Color::from_u32(0x2c3e4f), 59 | cyan_dark: Color::from_u32(0x3e666b), 60 | yellow_dark: Color::from_u32(0xa57f0d), 61 | magenta_dark: Color::from_u32(0x372e56), 62 | }; 63 | 64 | // Linearly interpolates between two different colors 65 | pub fn lerp(c1: Color, c2: Color, w: f32) -> Color { 66 | let (r1, g1, b1) = match c1 { 67 | Color::Rgb(r, g, b) => (r, g, b), 68 | _ => (0, 0, 0), 69 | }; 70 | 71 | let (r2, g2, b2) = match c2 { 72 | Color::Rgb(r, g, b) => (r, g, b), 73 | _ => (0, 0, 0), 74 | }; 75 | 76 | let r = if r1 > r2 { 77 | r2 + (((r1 - r2) as f32) * w) as u8 78 | } else { 79 | r1 + (((r2 - r1) as f32) * w) as u8 80 | }; 81 | 82 | let g = if g1 > g2 { 83 | g2 + (((g1 - g2) as f32) * w) as u8 84 | } else { 85 | g1 + (((g2 - g1) as f32) * w) as u8 86 | }; 87 | 88 | let b = if b1 > b2 { 89 | b2 + (((b1 - b2) as f32) * w) as u8 90 | } else { 91 | b1 + (((b2 - b1) as f32) * w) as u8 92 | }; 93 | 94 | Color::Rgb(r, g, b) 95 | } 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use super::*; 101 | 102 | #[test] 103 | fn test_lerp() { 104 | let c1 = Color::Rgb(200, 100, 50); 105 | let c2 = Color::Rgb(0, 0, 0); 106 | assert_eq!(Color::Rgb(100, 50, 25), Theme::lerp(c1, c2, 0.5)); 107 | assert_eq!(Color::Rgb(100, 50, 25), Theme::lerp(c2, c1, 0.5)); 108 | 109 | let c1 = Color::Rgb(200, 100, 50); 110 | let c2 = Color::Rgb(0, 0, 0); 111 | assert_eq!(c1, Theme::lerp(c1, c2, 1.0)); 112 | assert_eq!(c2, Theme::lerp(c1, c2, 0.0)); 113 | 114 | let c1 = Color::Rgb(3, 5, 7); 115 | let c2 = Color::Rgb(0, 0, 0); 116 | assert_eq!(Color::Rgb(1, 2, 3), Theme::lerp(c1, c2, 0.5)); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/terminal/system/src/tui.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, stdout, Stdout}; 2 | 3 | use crossterm::{ 4 | event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, 5 | execute, 6 | terminal::*, 7 | }; 8 | use ratatui::prelude::*; 9 | 10 | /// A type alias for the terminal type used in this application 11 | pub type Tui = Terminal>; 12 | 13 | /// Initialize the terminal 14 | pub fn init() -> io::Result { 15 | execute!( 16 | stdout(), 17 | EnterAlternateScreen, 18 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) 19 | )?; 20 | enable_raw_mode()?; 21 | set_panic_hook(); 22 | Terminal::new(CrosstermBackend::new(stdout())) 23 | } 24 | 25 | /// Restore the terminal to its original state 26 | pub fn restore() -> io::Result<()> { 27 | execute!(stdout(), LeaveAlternateScreen, PopKeyboardEnhancementFlags)?; 28 | disable_raw_mode()?; 29 | Ok(()) 30 | } 31 | 32 | fn set_panic_hook() { 33 | let hook = std::panic::take_hook(); 34 | std::panic::set_hook(Box::new(move |panic_info| { 35 | let _ = restore(); 36 | hook(panic_info); 37 | })); 38 | } 39 | -------------------------------------------------------------------------------- /src/terminal/system/src/window.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, io}; 2 | 3 | use crate::{theme::Theme, tui}; 4 | use crossterm::event::KeyEvent; 5 | use ratatui::widgets::block::*; 6 | 7 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 8 | pub enum WindowName { 9 | None, 10 | Menu, 11 | Database, 12 | } 13 | 14 | #[derive(Copy, Clone)] 15 | pub struct Window { 16 | pub quit: bool, 17 | pub change: bool, 18 | pub theme: Theme, 19 | pub border_type: BorderType, 20 | pub draw_background: bool, 21 | } 22 | 23 | pub trait Screen { 24 | fn new(window: Window) -> Self; 25 | fn run(&mut self, terminal: &mut tui::Tui) -> io::Result; 26 | fn handle_events(&mut self) -> io::Result<()>; 27 | fn handle_key_event(&mut self, key_event: KeyEvent); 28 | } 29 | 30 | impl Default for Window { 31 | fn default() -> Window { 32 | Window { 33 | quit: false, 34 | change: false, 35 | theme: Theme::AMBER, 36 | border_type: BorderType::Rounded, 37 | draw_background: true, 38 | } 39 | } 40 | } 41 | 42 | impl fmt::Display for WindowName { 43 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 44 | write!(f, "{:?}", self) 45 | } 46 | } 47 | --------------------------------------------------------------------------------