Skip to content

Commit 25a876f

Browse files
WendellXYclaude
andcommitted
feat(cli): add interactive TUI browser for localization files
New `langcodec browse --input <file>` subcommand opens a terminal UI for viewing, searching, and editing translations without an IDE — aimed at large xcstrings files that are slow to open in Xcode. Layout: 38% scrollable key list | 62% translations panel | status bar Key bindings: j/k / ↑↓ navigate keys Tab/Shift+Tab cycle language selection / incremental search (keys + values) e edit selected translation inline s save back to original file g/G jump to top/bottom q quit (prompts to save if dirty) Rendering details: - Non-selected language rows render in a height-1 Rect without Wrap, so long text is clipped at the border regardless of unicode-width accuracy or embedded newlines in the value. - Selected language gets up to 5 rows with Wrap for full readability. - Clear widget applied each frame to prevent stale cells from complex scripts (Bengali, Hindi, Arabic) whose terminal display width exceeds what unicode-width reports. - Save uses convert_resources_to_format with format inferred from the file extension so round-tripping is format-preserving. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 43cdde9 commit 25a876f

5 files changed

Lines changed: 906 additions & 0 deletions

File tree

langcodec-cli/src/editor/app.rs

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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

Comments
 (0)