|
| 1 | +use std::collections::HashSet; |
| 2 | + |
| 3 | +use langcodec::{ |
| 4 | + Codec, FormatType, Resource, Translation, |
| 5 | + types::EntryStatus, |
| 6 | +}; |
| 7 | +use ratatui::widgets::ListState; |
| 8 | + |
| 9 | +#[derive(Debug, Clone, PartialEq)] |
| 10 | +pub enum InputMode { |
| 11 | + Normal, |
| 12 | + Search, |
| 13 | + Edit, |
| 14 | + ConfirmQuit, |
| 15 | +} |
| 16 | + |
| 17 | +#[derive(Debug, Clone, Copy, PartialEq)] |
| 18 | +pub enum StatusTone { |
| 19 | + Success, |
| 20 | + Error, |
| 21 | +} |
| 22 | + |
| 23 | +pub struct App { |
| 24 | + pub codec: Codec, |
| 25 | + pub file_path: String, |
| 26 | + pub inferred_format: FormatType, |
| 27 | + /// All unique keys across all resources, sorted alphabetically |
| 28 | + pub all_keys: Vec<String>, |
| 29 | + /// Filtered subset of all_keys based on search_query |
| 30 | + pub filtered_keys: Vec<String>, |
| 31 | + /// Languages present, with "en" first if available |
| 32 | + pub languages: Vec<String>, |
| 33 | + /// ratatui list state for the key panel |
| 34 | + pub key_list_state: ListState, |
| 35 | + /// Index of the currently selected language in the translations panel |
| 36 | + pub selected_lang_index: usize, |
| 37 | + pub search_query: String, |
| 38 | + pub edit_buffer: String, |
| 39 | + pub input_mode: InputMode, |
| 40 | + pub dirty: bool, |
| 41 | + pub status_message: Option<(String, StatusTone)>, |
| 42 | +} |
| 43 | + |
| 44 | +impl App { |
| 45 | + pub fn new(codec: Codec, file_path: String, inferred_format: FormatType) -> Self { |
| 46 | + let mut key_set: HashSet<String> = HashSet::new(); |
| 47 | + for resource in &codec.resources { |
| 48 | + for entry in &resource.entries { |
| 49 | + key_set.insert(entry.id.clone()); |
| 50 | + } |
| 51 | + } |
| 52 | + let mut all_keys: Vec<String> = key_set.into_iter().collect(); |
| 53 | + all_keys.sort(); |
| 54 | + |
| 55 | + let mut languages: Vec<String> = codec |
| 56 | + .resources |
| 57 | + .iter() |
| 58 | + .map(|r| r.metadata.language.clone()) |
| 59 | + .filter(|l| !l.is_empty()) |
| 60 | + .collect(); |
| 61 | + languages.sort(); |
| 62 | + // Promote "en" to the front for readability |
| 63 | + if let Some(pos) = languages.iter().position(|l| l == "en") { |
| 64 | + languages.remove(pos); |
| 65 | + languages.insert(0, "en".to_string()); |
| 66 | + } |
| 67 | + |
| 68 | + let filtered_keys = all_keys.clone(); |
| 69 | + |
| 70 | + let mut key_list_state = ListState::default(); |
| 71 | + if !filtered_keys.is_empty() { |
| 72 | + key_list_state.select(Some(0)); |
| 73 | + } |
| 74 | + |
| 75 | + Self { |
| 76 | + codec, |
| 77 | + file_path, |
| 78 | + inferred_format, |
| 79 | + all_keys, |
| 80 | + filtered_keys, |
| 81 | + languages, |
| 82 | + key_list_state, |
| 83 | + selected_lang_index: 0, |
| 84 | + search_query: String::new(), |
| 85 | + edit_buffer: String::new(), |
| 86 | + input_mode: InputMode::Normal, |
| 87 | + dirty: false, |
| 88 | + status_message: None, |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + pub fn selected_key(&self) -> Option<&str> { |
| 93 | + self.key_list_state |
| 94 | + .selected() |
| 95 | + .and_then(|i| self.filtered_keys.get(i)) |
| 96 | + .map(|s| s.as_str()) |
| 97 | + } |
| 98 | + |
| 99 | + pub fn selected_language(&self) -> Option<&str> { |
| 100 | + self.languages.get(self.selected_lang_index).map(|s| s.as_str()) |
| 101 | + } |
| 102 | + |
| 103 | + pub fn get_translation(&self, key: &str, lang: &str) -> Option<String> { |
| 104 | + self.codec |
| 105 | + .get_by_language(lang)? |
| 106 | + .entries |
| 107 | + .iter() |
| 108 | + .find(|e| e.id == key) |
| 109 | + .and_then(|e| match &e.value { |
| 110 | + Translation::Singular(s) => Some(s.clone()), |
| 111 | + Translation::Empty => None, |
| 112 | + Translation::Plural(p) => { |
| 113 | + // Show the "other" form as a summary for plurals |
| 114 | + p.forms |
| 115 | + .get(&langcodec::types::PluralCategory::Other) |
| 116 | + .cloned() |
| 117 | + } |
| 118 | + }) |
| 119 | + } |
| 120 | + |
| 121 | + pub fn apply_filter(&mut self) { |
| 122 | + let query = self.search_query.to_lowercase(); |
| 123 | + if query.is_empty() { |
| 124 | + self.filtered_keys = self.all_keys.clone(); |
| 125 | + } else { |
| 126 | + self.filtered_keys = self |
| 127 | + .all_keys |
| 128 | + .iter() |
| 129 | + .filter(|k| { |
| 130 | + if k.to_lowercase().contains(&query) { |
| 131 | + return true; |
| 132 | + } |
| 133 | + // Also search translation values |
| 134 | + self.languages.iter().any(|lang| { |
| 135 | + self.get_translation(k, lang) |
| 136 | + .map(|v| v.to_lowercase().contains(&query)) |
| 137 | + .unwrap_or(false) |
| 138 | + }) |
| 139 | + }) |
| 140 | + .cloned() |
| 141 | + .collect(); |
| 142 | + } |
| 143 | + |
| 144 | + // Clamp or reset selection |
| 145 | + let new_len = self.filtered_keys.len(); |
| 146 | + if new_len == 0 { |
| 147 | + self.key_list_state.select(None); |
| 148 | + } else { |
| 149 | + let clamped = self.key_list_state.selected().unwrap_or(0).min(new_len - 1); |
| 150 | + self.key_list_state.select(Some(clamped)); |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + pub fn enter_edit_mode(&mut self) { |
| 155 | + let key = self.selected_key().map(|s| s.to_string()); |
| 156 | + let lang = self.selected_language().map(|s| s.to_string()); |
| 157 | + if let (Some(key), Some(lang)) = (key, lang) { |
| 158 | + self.edit_buffer = self.get_translation(&key, &lang).unwrap_or_default(); |
| 159 | + self.input_mode = InputMode::Edit; |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + pub fn commit_edit(&mut self) { |
| 164 | + let key = self.selected_key().map(|s| s.to_string()); |
| 165 | + let lang = self.selected_language().map(|s| s.to_string()); |
| 166 | + if let (Some(key), Some(lang)) = (key, lang) { |
| 167 | + let value = self.edit_buffer.clone(); |
| 168 | + let translation = Translation::Singular(value); |
| 169 | + let result = if self.codec.has_entry(&key, &lang) { |
| 170 | + self.codec.update_translation(&key, &lang, translation, None) |
| 171 | + } else { |
| 172 | + self.codec |
| 173 | + .add_entry(&key, &lang, translation, None, Some(EntryStatus::Translated)) |
| 174 | + }; |
| 175 | + match result { |
| 176 | + Ok(()) => { |
| 177 | + self.dirty = true; |
| 178 | + self.status_message = |
| 179 | + Some(("Translation updated".to_string(), StatusTone::Success)); |
| 180 | + } |
| 181 | + Err(e) => { |
| 182 | + self.status_message = |
| 183 | + Some((format!("Error: {}", e), StatusTone::Error)); |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + self.input_mode = InputMode::Normal; |
| 188 | + } |
| 189 | + |
| 190 | + pub fn cancel_edit(&mut self) { |
| 191 | + self.edit_buffer.clear(); |
| 192 | + self.input_mode = InputMode::Normal; |
| 193 | + } |
| 194 | + |
| 195 | + pub fn save(&mut self) -> Result<(), String> { |
| 196 | + let resources: Vec<Resource> = self.codec.resources.clone(); |
| 197 | + let format = self.inferred_format.clone(); |
| 198 | + langcodec::convert_resources_to_format(resources, &self.file_path, format) |
| 199 | + .map_err(|e| format!("Save failed: {}", e))?; |
| 200 | + self.dirty = false; |
| 201 | + self.status_message = Some(("Saved successfully".to_string(), StatusTone::Success)); |
| 202 | + Ok(()) |
| 203 | + } |
| 204 | + |
| 205 | + // — Key list navigation — |
| 206 | + |
| 207 | + pub fn key_next(&mut self) { |
| 208 | + if self.filtered_keys.is_empty() { |
| 209 | + return; |
| 210 | + } |
| 211 | + let len = self.filtered_keys.len(); |
| 212 | + let next = self |
| 213 | + .key_list_state |
| 214 | + .selected() |
| 215 | + .map(|i| (i + 1).min(len - 1)) |
| 216 | + .unwrap_or(0); |
| 217 | + self.key_list_state.select(Some(next)); |
| 218 | + self.status_message = None; |
| 219 | + } |
| 220 | + |
| 221 | + pub fn key_prev(&mut self) { |
| 222 | + if self.filtered_keys.is_empty() { |
| 223 | + return; |
| 224 | + } |
| 225 | + let prev = self |
| 226 | + .key_list_state |
| 227 | + .selected() |
| 228 | + .map(|i| i.saturating_sub(1)) |
| 229 | + .unwrap_or(0); |
| 230 | + self.key_list_state.select(Some(prev)); |
| 231 | + self.status_message = None; |
| 232 | + } |
| 233 | + |
| 234 | + pub fn key_next_page(&mut self, page_size: usize) { |
| 235 | + if self.filtered_keys.is_empty() { |
| 236 | + return; |
| 237 | + } |
| 238 | + let len = self.filtered_keys.len(); |
| 239 | + let next = self |
| 240 | + .key_list_state |
| 241 | + .selected() |
| 242 | + .map(|i| (i + page_size).min(len - 1)) |
| 243 | + .unwrap_or(0); |
| 244 | + self.key_list_state.select(Some(next)); |
| 245 | + self.status_message = None; |
| 246 | + } |
| 247 | + |
| 248 | + pub fn key_prev_page(&mut self, page_size: usize) { |
| 249 | + if self.filtered_keys.is_empty() { |
| 250 | + return; |
| 251 | + } |
| 252 | + let prev = self |
| 253 | + .key_list_state |
| 254 | + .selected() |
| 255 | + .map(|i| i.saturating_sub(page_size)) |
| 256 | + .unwrap_or(0); |
| 257 | + self.key_list_state.select(Some(prev)); |
| 258 | + self.status_message = None; |
| 259 | + } |
| 260 | + |
| 261 | + pub fn key_jump_top(&mut self) { |
| 262 | + if !self.filtered_keys.is_empty() { |
| 263 | + self.key_list_state.select(Some(0)); |
| 264 | + self.status_message = None; |
| 265 | + } |
| 266 | + } |
| 267 | + |
| 268 | + pub fn key_jump_bottom(&mut self) { |
| 269 | + if !self.filtered_keys.is_empty() { |
| 270 | + self.key_list_state.select(Some(self.filtered_keys.len() - 1)); |
| 271 | + self.status_message = None; |
| 272 | + } |
| 273 | + } |
| 274 | + |
| 275 | + // — Language navigation — |
| 276 | + |
| 277 | + pub fn lang_next(&mut self) { |
| 278 | + if self.languages.is_empty() { |
| 279 | + return; |
| 280 | + } |
| 281 | + self.selected_lang_index = |
| 282 | + (self.selected_lang_index + 1).min(self.languages.len() - 1); |
| 283 | + } |
| 284 | + |
| 285 | + pub fn lang_prev(&mut self) { |
| 286 | + self.selected_lang_index = self.selected_lang_index.saturating_sub(1); |
| 287 | + } |
| 288 | +} |
0 commit comments