refactor: ui widgets
14 files changed, +482 -318
+78-69src/app.rs
| @@ -53,19 +53,20 @@ pub struct JvApp { | ||
| 53 | 53 | dropped_files: Vec<egui::DroppedFile>, |
| 54 | 54 | |
| 55 | 55 | // FPS tracking |
| 56 | - frame_times: Vec<f64>, | |
| 56 | + frame_times: std::collections::VecDeque<f64>, | |
| 57 | 57 | fps_display: f64, |
| 58 | 58 | |
| 59 | 59 | // Async file dialog results |
| 60 | - file_dialog_rx: Option<std::sync::mpsc::Receiver<FileDialogResult>>, | |
| 60 | + file_dialog_rx: Option<std::sync::mpsc::Receiver<Vec<ImportedFile>>>, | |
| 61 | 61 | |
| 62 | 62 | // Async sample fetch from GitHub |
| 63 | 63 | samples_rx: Option<std::sync::mpsc::Receiver<Vec<SampleFile>>>, |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | -enum FileDialogResult { | |
| 67 | - Files(Vec<std::path::PathBuf>), | |
| 68 | - Directory(std::path::PathBuf), | |
| 66 | +struct ImportedFile { | |
| 67 | + filename: String, | |
| 68 | + content: String, | |
| 69 | + source: jv::session::FileSource, | |
| 69 | 70 | } |
| 70 | 71 | |
| 71 | 72 | /// Fetched sample file from GitHub |
| @@ -138,6 +139,50 @@ fn fetch_github_samples( | ||
| 138 | 139 | }); |
| 139 | 140 | } |
| 140 | 141 | |
| 142 | +/// Read a single file into ImportedFile(s). HAR files may produce multiple entries. | |
| 143 | +fn read_single_file(path: &std::path::Path) -> Option<Vec<ImportedFile>> { | |
| 144 | + let content = std::fs::read_to_string(path).ok()?; | |
| 145 | + let is_har = path.extension().is_some_and(|e| e == "har"); | |
| 146 | + if is_har { | |
| 147 | + let har_value: serde_json::Value = serde_json::from_str(&content).ok()?; | |
| 148 | + let files = jv::har::extract_har_files(&har_value); | |
| 149 | + Some(files.into_iter().map(|(filename, value)| { | |
| 150 | + ImportedFile { | |
| 151 | + filename, | |
| 152 | + content: serde_json::to_string_pretty(&value).unwrap_or_default(), | |
| 153 | + source: jv::session::FileSource::Har, | |
| 154 | + } | |
| 155 | + }).collect()) | |
| 156 | + } else { | |
| 157 | + let filename = path.file_name() | |
| 158 | + .and_then(|n| n.to_str()) | |
| 159 | + .unwrap_or("unknown.json") | |
| 160 | + .to_string(); | |
| 161 | + Some(vec![ImportedFile { | |
| 162 | + filename, | |
| 163 | + content, | |
| 164 | + source: jv::session::FileSource::Json, | |
| 165 | + }]) | |
| 166 | + } | |
| 167 | +} | |
| 168 | + | |
| 169 | +/// Read all JSON/HAR files from a directory tree. | |
| 170 | +fn read_directory_files(dir: &std::path::Path) -> Vec<ImportedFile> { | |
| 171 | + let mut result = Vec::new(); | |
| 172 | + for entry in walkdir::WalkDir::new(dir) | |
| 173 | + .into_iter() | |
| 174 | + .filter_map(|e| e.ok()) | |
| 175 | + { | |
| 176 | + let path = entry.path(); | |
| 177 | + if path.is_file() && path.extension().is_some_and(|e| e == "json" || e == "har") { | |
| 178 | + if let Some(files) = read_single_file(path) { | |
| 179 | + result.extend(files); | |
| 180 | + } | |
| 181 | + } | |
| 182 | + } | |
| 183 | + result | |
| 184 | +} | |
| 185 | + | |
| 141 | 186 | impl JvApp { |
| 142 | 187 | pub fn new(cc: &eframe::CreationContext<'_>) -> Self { |
| 143 | 188 | // Load Phosphor icon font |
| @@ -187,7 +232,7 @@ impl JvApp { | ||
| 187 | 232 | sidebar_width: 240.0, |
| 188 | 233 | theme_applied: false, |
| 189 | 234 | dropped_files: Vec::new(), |
| 190 | - frame_times: Vec::with_capacity(60), | |
| 235 | + frame_times: std::collections::VecDeque::with_capacity(60), | |
| 191 | 236 | fps_display: 0.0, |
| 192 | 237 | file_dialog_rx: None, |
| 193 | 238 | samples_rx, |
| @@ -233,46 +278,6 @@ impl JvApp { | ||
| 233 | 278 | } |
| 234 | 279 | } |
| 235 | 280 | |
| 236 | - fn import_file(&mut self, path: &std::path::Path) { | |
| 237 | - if let Ok(content) = std::fs::read_to_string(path) { | |
| 238 | - let is_har = path.extension().is_some_and(|e| e == "har"); | |
| 239 | - if is_har { | |
| 240 | - if let Ok(har_value) = serde_json::from_str::<serde_json::Value>(&content) { | |
| 241 | - let files = jv::har::extract_har_files(&har_value); | |
| 242 | - if let Some(loaded) = &mut self.current_session { | |
| 243 | - for (filename, value) in &files { | |
| 244 | - let json_str = | |
| 245 | - serde_json::to_string_pretty(value).unwrap_or_default(); | |
| 246 | - if loaded.add_file(filename, json_str, jv::session::FileSource::Har).is_ok() {} | |
| 247 | - } | |
| 248 | - self.session_manager.update_session(&loaded.session); | |
| 249 | - self.rebuild_schema(); | |
| 250 | - } | |
| 251 | - } | |
| 252 | - } else { | |
| 253 | - let path_str = path.to_string_lossy().to_string(); | |
| 254 | - if let Some(loaded) = &mut self.current_session { | |
| 255 | - if loaded.add_file(&path_str, content, jv::session::FileSource::Json).is_ok() { | |
| 256 | - self.session_manager.update_session(&loaded.session); | |
| 257 | - self.rebuild_schema(); | |
| 258 | - } | |
| 259 | - } | |
| 260 | - } | |
| 261 | - } | |
| 262 | - } | |
| 263 | - | |
| 264 | - fn import_directory(&mut self, dir: &std::path::Path) { | |
| 265 | - for entry in walkdir::WalkDir::new(dir) | |
| 266 | - .into_iter() | |
| 267 | - .filter_map(|e| e.ok()) | |
| 268 | - { | |
| 269 | - let path = entry.path(); | |
| 270 | - if path.is_file() && path.extension().is_some_and(|e| e == "json" || e == "har") { | |
| 271 | - self.import_file(path); | |
| 272 | - } | |
| 273 | - } | |
| 274 | - } | |
| 275 | - | |
| 276 | 281 | fn show_sidebar(&mut self, ui: &mut egui::Ui) { |
| 277 | 282 | ui.vertical(|ui| { |
| 278 | 283 | use egui_phosphor::regular; |
| @@ -369,7 +374,7 @@ impl JvApp { | ||
| 369 | 374 | let ctx = ui.ctx().clone(); |
| 370 | 375 | std::thread::spawn(move || { |
| 371 | 376 | if let Some(dir) = rfd::FileDialog::new().pick_folder() { |
| 372 | - let _ = tx.send(FileDialogResult::Directory(dir)); | |
| 377 | + let _ = tx.send(read_directory_files(&dir)); | |
| 373 | 378 | ctx.request_repaint(); |
| 374 | 379 | } |
| 375 | 380 | }); |
| @@ -385,7 +390,8 @@ impl JvApp { | ||
| 385 | 390 | .add_filter("JSON & HAR", &["json", "har"]) |
| 386 | 391 | .pick_files() |
| 387 | 392 | { |
| 388 | - let _ = tx.send(FileDialogResult::Files(paths)); | |
| 393 | + let files = paths.iter().filter_map(|p| read_single_file(p)).flatten().collect(); | |
| 394 | + let _ = tx.send(files); | |
| 389 | 395 | ctx.request_repaint(); |
| 390 | 396 | } |
| 391 | 397 | }); |
| @@ -452,8 +458,7 @@ impl JvApp { | ||
| 452 | 458 | for (idx, filename, source) in &files { |
| 453 | 459 | let is_disabled = self.disabled_files.contains(idx); |
| 454 | 460 | |
| 455 | - let row_id = ui.id().with(("file_row", idx)); | |
| 456 | - let is_hovered = ui.ctx().data(|d| d.get_temp::<bool>(row_id)).unwrap_or(false); | |
| 461 | + let (is_hovered, row_id) = jv::widgets::check_hover(ui.ctx(), ui.id().with(("file_row", idx))); | |
| 457 | 462 | |
| 458 | 463 | let bg = if is_hovered { |
| 459 | 464 | egui::Color32::from_rgba_premultiplied(40, 40, 55, 255) |
| @@ -489,8 +494,7 @@ impl JvApp { | ||
| 489 | 494 | .show(ui, |ui| { |
| 490 | 495 | ui.horizontal(|ui| { |
| 491 | 496 | // Trash icon |
| 492 | - let trash_id = ui.id().with(("trash", idx)); | |
| 493 | - let trash_hovered = ui.ctx().data(|d| d.get_temp::<bool>(trash_id)).unwrap_or(false); | |
| 497 | + let (trash_hovered, trash_id) = jv::widgets::check_hover(ui.ctx(), ui.id().with(("trash", idx))); | |
| 494 | 498 | let trash_color = if trash_hovered { |
| 495 | 499 | CatppuccinMocha::RED |
| 496 | 500 | } else { |
| @@ -501,7 +505,7 @@ impl JvApp { | ||
| 501 | 505 | .color(trash_color) |
| 502 | 506 | .size(12.0), |
| 503 | 507 | ); |
| 504 | - ui.ctx().data_mut(|d| d.insert_temp(trash_id, rm.hovered())); | |
| 508 | + jv::widgets::store_hover(ui.ctx(), trash_id, rm.hovered()); | |
| 505 | 509 | trash_rect = rm.rect; |
| 506 | 510 | |
| 507 | 511 | // Eye icon |
| @@ -549,7 +553,7 @@ impl JvApp { | ||
| 549 | 553 | .response |
| 550 | 554 | .interact(egui::Sense::click()); |
| 551 | 555 | |
| 552 | - ui.ctx().data_mut(|d| d.insert_temp(row_id, r.hovered())); | |
| 556 | + jv::widgets::store_hover(ui.ctx(), row_id, r.hovered()); | |
| 553 | 557 | |
| 554 | 558 | if r.clicked() { |
| 555 | 559 | if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { |
| @@ -927,11 +931,20 @@ impl JvApp { | ||
| 927 | 931 | let files: Vec<egui::DroppedFile> = self.dropped_files.drain(..).collect(); |
| 928 | 932 | for file in files { |
| 929 | 933 | if let Some(path) = &file.path { |
| 930 | - if path.is_dir() { | |
| 931 | - self.import_directory(path); | |
| 934 | + let imported = if path.is_dir() { | |
| 935 | + read_directory_files(path) | |
| 932 | 936 | } else if path.extension().is_some_and(|e| e == "json" || e == "har") { |
| 933 | - self.import_file(path); | |
| 937 | + read_single_file(path).unwrap_or_default() | |
| 938 | + } else { | |
| 939 | + Vec::new() | |
| 940 | + }; | |
| 941 | + if let Some(loaded) = &mut self.current_session { | |
| 942 | + for f in imported { | |
| 943 | + if loaded.add_file(&f.filename, f.content, f.source).is_ok() {} | |
| 944 | + } | |
| 945 | + self.session_manager.update_session(&loaded.session); | |
| 934 | 946 | } |
| 947 | + self.rebuild_schema(); | |
| 935 | 948 | } else if let Some(bytes) = &file.bytes { |
| 936 | 949 | let content = String::from_utf8_lossy(bytes).to_string(); |
| 937 | 950 | let name = file.name.clone(); |
| @@ -956,21 +969,17 @@ impl eframe::App for JvApp { | ||
| 956 | 969 | // Poll async file dialog results |
| 957 | 970 | if let Some(rx) = &self.file_dialog_rx { |
| 958 | 971 | match rx.try_recv() { |
| 959 | - Ok(result) => { | |
| 960 | - match result { | |
| 961 | - FileDialogResult::Files(paths) => { | |
| 962 | - for path in paths { | |
| 963 | - self.import_file(&path); | |
| 964 | - } | |
| 965 | - } | |
| 966 | - FileDialogResult::Directory(dir) => { | |
| 967 | - self.import_directory(&dir); | |
| 972 | + Ok(files) => { | |
| 973 | + if let Some(loaded) = &mut self.current_session { | |
| 974 | + for f in files { | |
| 975 | + if loaded.add_file(&f.filename, f.content, f.source).is_ok() {} | |
| 968 | 976 | } |
| 977 | + self.session_manager.update_session(&loaded.session); | |
| 969 | 978 | } |
| 979 | + self.rebuild_schema(); | |
| 970 | 980 | self.file_dialog_rx = None; |
| 971 | 981 | } |
| 972 | 982 | Err(std::sync::mpsc::TryRecvError::Disconnected) => { |
| 973 | - // Dialog was cancelled | |
| 974 | 983 | self.file_dialog_rx = None; |
| 975 | 984 | } |
| 976 | 985 | Err(std::sync::mpsc::TryRecvError::Empty) => {} |
| @@ -1031,9 +1040,9 @@ impl eframe::App for JvApp { | ||
| 1031 | 1040 | |
| 1032 | 1041 | // FPS tracking (rolling average of last 60 frames) |
| 1033 | 1042 | let dt = ctx.input(|i| i.stable_dt) as f64; |
| 1034 | - self.frame_times.push(dt); | |
| 1043 | + self.frame_times.push_back(dt); | |
| 1035 | 1044 | if self.frame_times.len() > 60 { |
| 1036 | - self.frame_times.remove(0); | |
| 1045 | + self.frame_times.pop_front(); | |
| 1037 | 1046 | } |
| 1038 | 1047 | if !self.frame_times.is_empty() { |
| 1039 | 1048 | let avg_dt: f64 = self.frame_times.iter().sum::<f64>() / self.frame_times.len() as f64; |
+118-123src/codegen.rs
| @@ -12,13 +12,86 @@ pub struct GeneratedStruct { | ||
| 12 | 12 | pub fields: Vec<GeneratedField>, |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | +/// Structured representation of a resolved type, replacing string-based manipulation. | |
| 16 | +#[derive(Debug, Clone, PartialEq)] | |
| 17 | +pub enum ResolvedType { | |
| 18 | + /// A known struct name (e.g., "Passenger") | |
| 19 | + Struct(String), | |
| 20 | + /// Vec<inner> | |
| 21 | + Array(Box<ResolvedType>), | |
| 22 | + /// Option<inner> | |
| 23 | + Optional(Box<ResolvedType>), | |
| 24 | + /// Primitive type — use lang.type_name() to render | |
| 25 | + Inferred(InferredType), | |
| 26 | +} | |
| 27 | + | |
| 28 | +impl ResolvedType { | |
| 29 | + /// Render to language-specific code (e.g., `Vec<Option<Passenger>>` for Rust, `[Passenger?]` for Swift) | |
| 30 | + pub fn to_code(&self, lang: &dyn crate::lang::LanguageGenerator) -> String { | |
| 31 | + match self { | |
| 32 | + ResolvedType::Struct(name) => name.clone(), | |
| 33 | + ResolvedType::Array(inner) => lang.wrap_array(&inner.to_code(lang)), | |
| 34 | + ResolvedType::Optional(inner) => lang.wrap_optional(&inner.to_code(lang)), | |
| 35 | + ResolvedType::Inferred(t) => lang.type_name(t), | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + /// Prefix struct names that aren't in shared_names and aren't the root | |
| 40 | + pub fn prefix_structs( | |
| 41 | + &self, | |
| 42 | + prefix: &str, | |
| 43 | + shared_names: &std::collections::BTreeSet<String>, | |
| 44 | + root_name: &str, | |
| 45 | + ) -> ResolvedType { | |
| 46 | + match self { | |
| 47 | + ResolvedType::Struct(name) => { | |
| 48 | + if !shared_names.contains(name) | |
| 49 | + && name != root_name | |
| 50 | + && !name.starts_with(prefix) | |
| 51 | + { | |
| 52 | + ResolvedType::Struct(format!("{}{}", prefix, name)) | |
| 53 | + } else { | |
| 54 | + self.clone() | |
| 55 | + } | |
| 56 | + } | |
| 57 | + ResolvedType::Array(inner) => { | |
| 58 | + ResolvedType::Array(Box::new(inner.prefix_structs(prefix, shared_names, root_name))) | |
| 59 | + } | |
| 60 | + ResolvedType::Optional(inner) => { | |
| 61 | + ResolvedType::Optional(Box::new(inner.prefix_structs(prefix, shared_names, root_name))) | |
| 62 | + } | |
| 63 | + ResolvedType::Inferred(_) => self.clone(), | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + /// Extract all struct names referenced in this type | |
| 68 | + pub fn struct_names(&self) -> Vec<&str> { | |
| 69 | + match self { | |
| 70 | + ResolvedType::Struct(name) => vec![name.as_str()], | |
| 71 | + ResolvedType::Array(inner) | ResolvedType::Optional(inner) => inner.struct_names(), | |
| 72 | + ResolvedType::Inferred(_) => vec![], | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// Wrap in Optional if not already optional | |
| 77 | + pub fn make_optional(self) -> ResolvedType { | |
| 78 | + match self { | |
| 79 | + ResolvedType::Optional(_) => self, | |
| 80 | + other => ResolvedType::Optional(Box::new(other)), | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + pub fn is_optional(&self) -> bool { | |
| 85 | + matches!(self, ResolvedType::Optional(_)) | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 15 | 89 | #[derive(Debug, Clone)] |
| 16 | 90 | pub struct GeneratedField { |
| 17 | 91 | pub json_name: String, |
| 18 | 92 | pub inferred_type: InferredType, |
| 19 | - /// Set when type is a child struct name (e.g., "Passenger", "Vec<Passenger>") | |
| 20 | - /// generate_code uses this instead of lang.type_name() when present | |
| 21 | - pub resolved_type: Option<String>, | |
| 93 | + /// Resolved struct type — None means use inferred_type via lang.type_name() | |
| 94 | + pub resolved_type: Option<ResolvedType>, | |
| 22 | 95 | pub needs_rename: bool, |
| 23 | 96 | } |
| 24 | 97 | |
| @@ -81,14 +154,14 @@ impl CodeGenerator { | ||
| 81 | 154 | serde_json::Value::Object(_) => { |
| 82 | 155 | let child_name = to_pascal_case(key); |
| 83 | 156 | Self::collect_structs(val, &child_name, structs, seen); |
| 84 | - Some(child_name) | |
| 157 | + Some(ResolvedType::Struct(child_name)) | |
| 85 | 158 | } |
| 86 | 159 | serde_json::Value::Array(arr) => { |
| 87 | 160 | if let Some(first) = arr.first() { |
| 88 | 161 | if first.is_object() { |
| 89 | 162 | let child_name = to_pascal_case(&singularize(key)); |
| 90 | 163 | Self::collect_structs(first, &child_name, structs, seen); |
| 91 | - Some(format!("Vec<{}>", child_name)) | |
| 164 | + Some(ResolvedType::Array(Box::new(ResolvedType::Struct(child_name)))) | |
| 92 | 165 | } else { |
| 93 | 166 | None |
| 94 | 167 | } |
| @@ -109,15 +182,15 @@ impl CodeGenerator { | ||
| 109 | 182 | |
| 110 | 183 | // Check if an existing struct with this name has identical fields |
| 111 | 184 | if seen.contains(name) { |
| 112 | - let field_sig: Vec<(&str, &InferredType, Option<&str>)> = fields | |
| 185 | + let field_sig: Vec<(&str, &InferredType, Option<&ResolvedType>)> = fields | |
| 113 | 186 | .iter() |
| 114 | - .map(|f| (f.json_name.as_str(), &f.inferred_type, f.resolved_type.as_deref())) | |
| 187 | + .map(|f| (f.json_name.as_str(), &f.inferred_type, f.resolved_type.as_ref())) | |
| 115 | 188 | .collect(); |
| 116 | 189 | let already_exists = structs.iter().any(|s| { |
| 117 | 190 | s.name == name && s.fields.len() == fields.len() && { |
| 118 | - let existing_sig: Vec<(&str, &InferredType, Option<&str>)> = s.fields | |
| 191 | + let existing_sig: Vec<(&str, &InferredType, Option<&ResolvedType>)> = s.fields | |
| 119 | 192 | .iter() |
| 120 | - .map(|f| (f.json_name.as_str(), &f.inferred_type, f.resolved_type.as_deref())) | |
| 193 | + .map(|f| (f.json_name.as_str(), &f.inferred_type, f.resolved_type.as_ref())) | |
| 121 | 194 | .collect(); |
| 122 | 195 | existing_sig == field_sig |
| 123 | 196 | } |
| @@ -157,7 +230,7 @@ impl CodeGenerator { | ||
| 157 | 230 | for field in &s.fields { |
| 158 | 231 | let code_name = lang.field_name(&field.json_name); |
| 159 | 232 | let type_str = match &field.resolved_type { |
| 160 | - Some(rt) => localize_type(rt, lang), | |
| 233 | + Some(rt) => rt.to_code(lang), | |
| 161 | 234 | None => lang.type_name(&field.inferred_type), |
| 162 | 235 | }; |
| 163 | 236 | body.push_str(&lang.field_line(&code_name, &type_str, &field.json_name)); |
| @@ -180,26 +253,6 @@ impl CodeGenerator { | ||
| 180 | 253 | } |
| 181 | 254 | } |
| 182 | 255 | |
| 183 | -/// Convert a resolved type (always stored in Rust syntax) to the target language. | |
| 184 | -/// Unwraps `Vec<T>` and `Option<T>` wrappers, passes the inner struct name through, | |
| 185 | -/// and re-wraps using the language's array/option syntax. | |
| 186 | -pub fn localize_type(rust_type: &str, lang: &dyn crate::lang::LanguageGenerator) -> String { | |
| 187 | - if rust_type.starts_with("Vec<") && rust_type.ends_with('>') { | |
| 188 | - let inner = &rust_type[4..rust_type.len() - 1]; | |
| 189 | - let localized_inner = localize_type(inner, lang); | |
| 190 | - lang.type_name(&InferredType::Array(Box::new(InferredType::String))) | |
| 191 | - .replace("String", &localized_inner) | |
| 192 | - } else if rust_type.starts_with("Option<") && rust_type.ends_with('>') { | |
| 193 | - let inner = &rust_type[7..rust_type.len() - 1]; | |
| 194 | - let localized_inner = localize_type(inner, lang); | |
| 195 | - lang.type_name(&InferredType::Option(Box::new(InferredType::String))) | |
| 196 | - .replace("String", &localized_inner) | |
| 197 | - } else { | |
| 198 | - // Bare struct name — pass through as-is | |
| 199 | - rust_type.to_string() | |
| 200 | - } | |
| 201 | -} | |
| 202 | - | |
| 203 | 256 | /// A generated project file (name + code content) |
| 204 | 257 | pub struct GeneratedProjectFile { |
| 205 | 258 | pub name: String, |
| @@ -342,16 +395,18 @@ pub fn generate_project( | ||
| 342 | 395 | let mut field_pairs: Vec<(String, String)> = Vec::new(); |
| 343 | 396 | for field in fields { |
| 344 | 397 | let code_name = lang.field_name(&field.json_name); |
| 345 | - let base_type = match &field.resolved_type { | |
| 346 | - Some(rt) => localize_type(rt, lang), | |
| 398 | + let type_str = match &field.resolved_type { | |
| 399 | + Some(rt) => { | |
| 400 | + let prefixed = if !prefix.is_empty() { | |
| 401 | + rt.prefix_structs(&prefix, &all_schema_names, struct_name) | |
| 402 | + } else { | |
| 403 | + rt.clone() | |
| 404 | + }; | |
| 405 | + prefixed.to_code(lang) | |
| 406 | + } | |
| 347 | 407 | None => lang.type_name(&field.inferred_type), |
| 348 | 408 | }; |
| 349 | - let resolved_type = if !prefix.is_empty() && !all_schema_names.contains(&base_type) { | |
| 350 | - prefix_resolved_type(&base_type, &prefix, &all_schema_names, struct_name) | |
| 351 | - } else { | |
| 352 | - base_type | |
| 353 | - }; | |
| 354 | - code.push_str(&lang.field_line(&code_name, &resolved_type, &field.json_name)); | |
| 409 | + code.push_str(&lang.field_line(&code_name, &type_str, &field.json_name)); | |
| 355 | 410 | field_pairs.push((code_name, field.json_name.clone())); |
| 356 | 411 | } |
| 357 | 412 | code.push_str(&lang.struct_close(&field_pairs)); |
| @@ -436,9 +491,9 @@ pub fn resolve_codegen_against_schema( | ||
| 436 | 491 | for s in &gen.structs { |
| 437 | 492 | for field in &s.fields { |
| 438 | 493 | if let Some(rt) = &field.resolved_type { |
| 439 | - for name in extract_struct_names_from_resolved(rt) { | |
| 440 | - if !existing_names.contains(&name) && !shared_names.contains(&name) { | |
| 441 | - needed.push(name); | |
| 494 | + for name in rt.struct_names() { | |
| 495 | + if !existing_names.contains(name) && !shared_names.contains(name) { | |
| 496 | + needed.push(name.to_string()); | |
| 442 | 497 | } |
| 443 | 498 | } |
| 444 | 499 | } |
| @@ -454,8 +509,8 @@ pub fn resolve_codegen_against_schema( | ||
| 454 | 509 | let fields: Vec<GeneratedField> = ss.fields.iter().map(|(key, typ)| { |
| 455 | 510 | let resolved = resolve_type_to_struct(typ, all_structs); |
| 456 | 511 | if let Some(rt) = &resolved { |
| 457 | - for dep in extract_struct_names_from_resolved(rt) { | |
| 458 | - needed.push(dep); | |
| 512 | + for dep in rt.struct_names() { | |
| 513 | + needed.push(dep.to_string()); | |
| 459 | 514 | } |
| 460 | 515 | } |
| 461 | 516 | GeneratedField { |
| @@ -484,27 +539,16 @@ fn merge_generated_fields(existing: &mut Vec<GeneratedField>, new_fields: &[Gene | ||
| 484 | 539 | .collect(); |
| 485 | 540 | |
| 486 | 541 | for field in existing.iter_mut() { |
| 487 | - match new_map.get(field.json_name.as_str()) { | |
| 488 | - None => { | |
| 489 | - // Field missing in new instance — make it Optional | |
| 490 | - if !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) { | |
| 491 | - field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone())); | |
| 492 | - field.resolved_type = field.resolved_type.take().map(|rt| { | |
| 493 | - if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) } | |
| 494 | - }); | |
| 495 | - } | |
| 496 | - } | |
| 497 | - Some(new_field) => { | |
| 498 | - // Field present but type might differ (e.g., String vs Null) | |
| 499 | - if new_field.inferred_type == InferredType::Null | |
| 500 | - && !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) | |
| 501 | - { | |
| 502 | - field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone())); | |
| 503 | - field.resolved_type = field.resolved_type.take().map(|rt| { | |
| 504 | - if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) } | |
| 505 | - }); | |
| 506 | - } | |
| 507 | - } | |
| 542 | + let should_optionalize = match new_map.get(field.json_name.as_str()) { | |
| 543 | + None => true, | |
| 544 | + Some(new_field) => new_field.inferred_type == InferredType::Null, | |
| 545 | + }; | |
| 546 | + | |
| 547 | + if should_optionalize | |
| 548 | + && !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) | |
| 549 | + { | |
| 550 | + field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone())); | |
| 551 | + field.resolved_type = field.resolved_type.take().map(|rt| rt.make_optional()); | |
| 508 | 552 | } |
| 509 | 553 | } |
| 510 | 554 | |
| @@ -515,64 +559,13 @@ fn merge_generated_fields(existing: &mut Vec<GeneratedField>, new_fields: &[Gene | ||
| 515 | 559 | let mut field = new_field.clone(); |
| 516 | 560 | if !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) { |
| 517 | 561 | field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone())); |
| 518 | - field.resolved_type = field.resolved_type.take().map(|rt| { | |
| 519 | - if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) } | |
| 520 | - }); | |
| 562 | + field.resolved_type = field.resolved_type.take().map(|rt| rt.make_optional()); | |
| 521 | 563 | } |
| 522 | 564 | existing.push(field); |
| 523 | 565 | } |
| 524 | 566 | } |
| 525 | 567 | } |
| 526 | 568 | |
| 527 | -fn extract_struct_names_from_resolved(rt: &str) -> Vec<String> { | |
| 528 | - let mut names = Vec::new(); | |
| 529 | - let inner = rt | |
| 530 | - .strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) | |
| 531 | - .or_else(|| rt.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>'))); | |
| 532 | - if let Some(inner) = inner { | |
| 533 | - names.extend(extract_struct_names_from_resolved(inner)); | |
| 534 | - } else if !rt.is_empty() && rt.chars().next().unwrap().is_ascii_uppercase() { | |
| 535 | - names.push(rt.to_string()); | |
| 536 | - } | |
| 537 | - names | |
| 538 | -} | |
| 539 | - | |
| 540 | -fn is_struct_name(s: &str) -> bool { | |
| 541 | - let first = s.chars().next().unwrap_or('a'); | |
| 542 | - first.is_ascii_uppercase() | |
| 543 | - && !s.contains('<') | |
| 544 | - && !s.contains('[') | |
| 545 | - && !matches!( | |
| 546 | - s, | |
| 547 | - "String" | "Vec" | "Option" | "bool" | "i64" | "u64" | "f64" | "i32" | "u32" | "f32" | |
| 548 | - | "NaiveDate" | "NaiveTime" | |
| 549 | - | "Bool" | "Int" | "Double" | "Date" | "Any" | |
| 550 | - ) | |
| 551 | -} | |
| 552 | - | |
| 553 | -fn prefix_resolved_type( | |
| 554 | - type_str: &str, | |
| 555 | - prefix: &str, | |
| 556 | - shared_names: &std::collections::BTreeSet<String>, | |
| 557 | - root_name: &str, | |
| 558 | -) -> String { | |
| 559 | - if type_str.starts_with("Vec<") && type_str.ends_with('>') { | |
| 560 | - let inner = &type_str[4..type_str.len() - 1]; | |
| 561 | - format!("Vec<{}>", prefix_resolved_type(inner, prefix, shared_names, root_name)) | |
| 562 | - } else if type_str.starts_with("Option<") && type_str.ends_with('>') { | |
| 563 | - let inner = &type_str[7..type_str.len() - 1]; | |
| 564 | - format!("Option<{}>", prefix_resolved_type(inner, prefix, shared_names, root_name)) | |
| 565 | - } else if is_struct_name(type_str) | |
| 566 | - && !shared_names.contains(type_str) | |
| 567 | - && type_str != root_name | |
| 568 | - && !type_str.starts_with(prefix) | |
| 569 | - { | |
| 570 | - format!("{}{}", prefix, type_str) | |
| 571 | - } else { | |
| 572 | - type_str.to_string() | |
| 573 | - } | |
| 574 | -} | |
| 575 | - | |
| 576 | 569 | pub fn to_snake_case(s: &str) -> String { |
| 577 | 570 | let mut result = String::new(); |
| 578 | 571 | for (i, ch) in s.chars().enumerate() { |
| @@ -679,21 +672,23 @@ pub fn first_normal_word(filename: &str) -> Option<String> { | ||
| 679 | 672 | None |
| 680 | 673 | } |
| 681 | 674 | |
| 682 | -/// Resolve an InferredType to a struct name if it contains an Object matching a known struct. | |
| 675 | +/// Resolve an InferredType to a ResolvedType if it contains an Object matching a known struct. | |
| 683 | 676 | /// Recursively handles Vec<Object>, Option<Object>, Option<Vec<Object>>, etc. |
| 684 | 677 | pub fn resolve_type_to_struct( |
| 685 | 678 | typ: &InferredType, |
| 686 | 679 | shared: &[crate::schema::SharedStruct], |
| 687 | -) -> Option<String> { | |
| 680 | +) -> Option<ResolvedType> { | |
| 688 | 681 | match typ { |
| 689 | - InferredType::Object(fields) => crate::types::resolve_struct_name(fields, shared), | |
| 682 | + InferredType::Object(fields) => { | |
| 683 | + crate::types::resolve_struct_name(fields, shared).map(ResolvedType::Struct) | |
| 684 | + } | |
| 690 | 685 | InferredType::Array(inner) => { |
| 691 | - let inner_resolved = resolve_type_to_struct(inner, shared); | |
| 692 | - inner_resolved.map(|name| format!("Vec<{}>", name)) | |
| 686 | + resolve_type_to_struct(inner, shared) | |
| 687 | + .map(|rt| ResolvedType::Array(Box::new(rt))) | |
| 693 | 688 | } |
| 694 | 689 | InferredType::Option(inner) => { |
| 695 | - let inner_resolved = resolve_type_to_struct(inner, shared); | |
| 696 | - inner_resolved.map(|name| format!("Option<{}>", name)) | |
| 690 | + resolve_type_to_struct(inner, shared) | |
| 691 | + .map(|rt| ResolvedType::Optional(Box::new(rt))) | |
| 697 | 692 | } |
| 698 | 693 | _ => None, |
| 699 | 694 | } |
+2-0src/lang/mod.rs
| @@ -37,6 +37,8 @@ pub trait LanguageGenerator { | ||
| 37 | 37 | fn enum_close(&self) -> String; |
| 38 | 38 | fn enum_variant(&self, variant_name: &str, json_value: &str) -> String; |
| 39 | 39 | fn type_name(&self, inferred: &InferredType) -> String; |
| 40 | + fn wrap_array(&self, inner: &str) -> String; | |
| 41 | + fn wrap_optional(&self, inner: &str) -> String; | |
| 40 | 42 | fn mod_file(&self, file_names: &[&str]) -> Option<String>; |
| 41 | 43 | fn sanitize_keyword(&self, name: &str) -> String; |
| 42 | 44 | fn field_name(&self, json_name: &str) -> String; |
+8-0src/lang/rust.rs
| @@ -100,6 +100,14 @@ impl LanguageGenerator for RustGenerator { | ||
| 100 | 100 | } |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | + fn wrap_array(&self, inner: &str) -> String { | |
| 104 | + format!("Vec<{}>", inner) | |
| 105 | + } | |
| 106 | + | |
| 107 | + fn wrap_optional(&self, inner: &str) -> String { | |
| 108 | + format!("Option<{}>", inner) | |
| 109 | + } | |
| 110 | + | |
| 103 | 111 | fn mod_file(&self, file_names: &[&str]) -> Option<String> { |
| 104 | 112 | let mut out = String::new(); |
| 105 | 113 | for name in file_names { |
+8-0src/lang/swift.rs
| @@ -151,6 +151,14 @@ impl LanguageGenerator for SwiftGenerator { | ||
| 151 | 151 | } |
| 152 | 152 | } |
| 153 | 153 | |
| 154 | + fn wrap_array(&self, inner: &str) -> String { | |
| 155 | + format!("[{}]", inner) | |
| 156 | + } | |
| 157 | + | |
| 158 | + fn wrap_optional(&self, inner: &str) -> String { | |
| 159 | + format!("{}?", inner) | |
| 160 | + } | |
| 161 | + | |
| 154 | 162 | fn mod_file(&self, _file_names: &[&str]) -> Option<String> { |
| 155 | 163 | None |
| 156 | 164 | } |
+1-0src/lib.rs
| @@ -8,3 +8,4 @@ pub mod temporal; | ||
| 8 | 8 | pub mod theme; |
| 9 | 9 | pub mod types; |
| 10 | 10 | pub mod views; |
| 11 | +pub mod widgets; |
+7-52src/views/browser.rs
| @@ -200,56 +200,21 @@ impl BrowserView { | ||
| 200 | 200 | }); |
| 201 | 201 | |
| 202 | 202 | if !jq_has_focus { |
| 203 | - let down = ui.input(|i| { | |
| 204 | - i.key_pressed(egui::Key::J) || i.key_pressed(egui::Key::ArrowDown) | |
| 205 | - }); | |
| 206 | - let up = ui.input(|i| { | |
| 207 | - i.key_pressed(egui::Key::K) || i.key_pressed(egui::Key::ArrowUp) | |
| 208 | - }); | |
| 209 | - let right = ui.input(|i| { | |
| 210 | - i.key_pressed(egui::Key::L) | |
| 211 | - || i.key_pressed(egui::Key::ArrowRight) | |
| 212 | - || i.key_pressed(egui::Key::Enter) | |
| 213 | - }); | |
| 214 | - let left = ui.input(|i| { | |
| 215 | - i.key_pressed(egui::Key::H) | |
| 216 | - || i.key_pressed(egui::Key::ArrowLeft) | |
| 217 | - || i.key_pressed(egui::Key::Backspace) | |
| 218 | - }); | |
| 219 | - let go_top = ui.input(|i| i.key_pressed(egui::Key::G)); | |
| 220 | - let go_bottom = ui.input(|i| i.key_pressed(egui::Key::End)); | |
| 221 | - let go_home = ui.input(|i| i.key_pressed(egui::Key::Home)); | |
| 222 | - | |
| 223 | - if down && !current_entries.is_empty() { | |
| 224 | - self.selection = (self.selection + 1).min(current_entries.len() - 1); | |
| 203 | + let action = crate::widgets::read_miller_keys(ui, false); | |
| 204 | + if crate::widgets::apply_selection(&mut self.selection, action, current_entries.len()) { | |
| 225 | 205 | self.scroll_to_selection = true; |
| 226 | 206 | self.sync_jq_from_path(); |
| 227 | 207 | } |
| 228 | - if up { | |
| 229 | - self.selection = self.selection.saturating_sub(1); | |
| 230 | - self.scroll_to_selection = true; | |
| 231 | - self.sync_jq_from_path(); | |
| 232 | - } | |
| 233 | - if right { | |
| 208 | + if action == crate::widgets::MillerAction::Enter { | |
| 234 | 209 | if let Some(entry) = current_entries.get(self.selection) { |
| 235 | 210 | if entry.is_container { |
| 236 | 211 | self.enter_selected(current, ¤t_entries); |
| 237 | 212 | } |
| 238 | 213 | } |
| 239 | 214 | } |
| 240 | - if left && !self.path.is_empty() { | |
| 215 | + if action == crate::widgets::MillerAction::Back && !self.path.is_empty() { | |
| 241 | 216 | self.go_up(); |
| 242 | 217 | } |
| 243 | - if go_top || go_home { | |
| 244 | - self.selection = 0; | |
| 245 | - self.scroll_to_selection = true; | |
| 246 | - self.sync_jq_from_path(); | |
| 247 | - } | |
| 248 | - if go_bottom && !current_entries.is_empty() { | |
| 249 | - self.selection = current_entries.len() - 1; | |
| 250 | - self.scroll_to_selection = true; | |
| 251 | - self.sync_jq_from_path(); | |
| 252 | - } | |
| 253 | 218 | |
| 254 | 219 | // Copy selected value: c or Ctrl+C |
| 255 | 220 | let copy = ui.input(|i| i.key_pressed(egui::Key::C)) |
| @@ -529,15 +494,7 @@ impl BrowserView { | ||
| 529 | 494 | } |
| 530 | 495 | |
| 531 | 496 | fn draw_separator(ui: &mut Ui, height: f32) { |
| 532 | - let sep_rect = ui.available_rect_before_wrap(); | |
| 533 | - ui.painter().line_segment( | |
| 534 | - [ | |
| 535 | - egui::pos2(sep_rect.left(), sep_rect.top()), | |
| 536 | - egui::pos2(sep_rect.left(), sep_rect.top() + height), | |
| 537 | - ], | |
| 538 | - egui::Stroke::new(1.0, CatppuccinMocha::SURFACE0), | |
| 539 | - ); | |
| 540 | - ui.add_space(4.0); | |
| 497 | + crate::widgets::draw_separator(ui, height); | |
| 541 | 498 | } |
| 542 | 499 | |
| 543 | 500 | fn render_parent_column( |
| @@ -641,9 +598,7 @@ impl BrowserView { | ||
| 641 | 598 | let entry = &entries[i]; |
| 642 | 599 | let is_selected = i == selection; |
| 643 | 600 | |
| 644 | - // Check hover from previous frame | |
| 645 | - let row_id = ui.id().with(("row", i)); | |
| 646 | - let is_hovered = ui.ctx().data(|d| d.get_temp::<bool>(row_id)).unwrap_or(false); | |
| 601 | + let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i); | |
| 647 | 602 | |
| 648 | 603 | let bg = if is_selected { |
| 649 | 604 | CatppuccinMocha::SURFACE0 |
| @@ -758,7 +713,7 @@ impl BrowserView { | ||
| 758 | 713 | }); |
| 759 | 714 | |
| 760 | 715 | let response = r.response.interact(egui::Sense::click()); |
| 761 | - ui.ctx().data_mut(|d| d.insert_temp(row_id, response.hovered())); | |
| 716 | + crate::widgets::store_hover(ui.ctx(), row_id, response.hovered()); | |
| 762 | 717 | |
| 763 | 718 | if response.clicked() && !copy_clicked { |
| 764 | 719 | clicked = Some(i); |
+68-31src/views/code.rs
| @@ -76,6 +76,8 @@ pub struct CodeView { | ||
| 76 | 76 | enum_popup: EnumPopup, |
| 77 | 77 | // Selected code language |
| 78 | 78 | selected_language: crate::lang::CodeLanguage, |
| 79 | + // Async download state | |
| 80 | + download_rx: Option<std::sync::mpsc::Receiver<Result<usize, String>>>, | |
| 79 | 81 | } |
| 80 | 82 | |
| 81 | 83 | impl CodeView { |
| @@ -96,6 +98,7 @@ impl CodeView { | ||
| 96 | 98 | hidden_fields: BTreeSet::new(), |
| 97 | 99 | enum_popup: EnumPopup::new(), |
| 98 | 100 | selected_language: crate::lang::CodeLanguage::Rust, |
| 101 | + download_rx: None, | |
| 99 | 102 | } |
| 100 | 103 | } |
| 101 | 104 | |
| @@ -149,20 +152,28 @@ impl CodeView { | ||
| 149 | 152 | // Rebuild hidden_fields from session state |
| 150 | 153 | self.hidden_fields = hidden_fields.iter().cloned().collect(); |
| 151 | 154 | |
| 155 | + // Poll async download result | |
| 156 | + if let Some(rx) = &self.download_rx { | |
| 157 | + match rx.try_recv() { | |
| 158 | + Ok(Ok(_count)) => { self.download_rx = None; } | |
| 159 | + Ok(Err(_err)) => { self.download_rx = None; } | |
| 160 | + Err(std::sync::mpsc::TryRecvError::Disconnected) => { self.download_rx = None; } | |
| 161 | + Err(std::sync::mpsc::TryRecvError::Empty) => {} | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 152 | 165 | let enum_count = enum_conversions.len(); |
| 153 | 166 | let hidden_count = hidden_fields.len(); |
| 154 | - let key = { | |
| 155 | - use std::hash::{Hash, Hasher}; | |
| 156 | - let mut h = std::collections::hash_map::DefaultHasher::new(); | |
| 157 | - parsed_files.len().hash(&mut h); | |
| 167 | + let key = crate::widgets::hash_key(|h| { | |
| 168 | + use std::hash::Hash; | |
| 169 | + parsed_files.len().hash(h); | |
| 158 | 170 | for (name, _) in parsed_files { |
| 159 | - name.hash(&mut h); | |
| 171 | + name.hash(h); | |
| 160 | 172 | } |
| 161 | - enum_count.hash(&mut h); | |
| 162 | - hidden_count.hash(&mut h); | |
| 163 | - (self.selected_language as u64).hash(&mut h); | |
| 164 | - h.finish() | |
| 165 | - }; | |
| 173 | + enum_count.hash(h); | |
| 174 | + hidden_count.hash(h); | |
| 175 | + (self.selected_language as u64).hash(h); | |
| 176 | + }); | |
| 166 | 177 | if self.cache_key != key { |
| 167 | 178 | self.rebuild_file_mode(parsed_files, schema); |
| 168 | 179 | self.build_struct_index(); |
| @@ -247,8 +258,7 @@ impl CodeView { | ||
| 247 | 258 | prev_was_group = file.is_group; |
| 248 | 259 | |
| 249 | 260 | let is_selected = idx == self.selected_file; |
| 250 | - let row_id = ui.id().with(("code_file", idx)); | |
| 251 | - let is_hovered = ui.ctx().data(|d| d.get_temp::<bool>(row_id)).unwrap_or(false); | |
| 261 | + let (is_hovered, row_id) = crate::widgets::check_hover(ui.ctx(), ui.id().with(("code_file", idx))); | |
| 252 | 262 | |
| 253 | 263 | let bg = if is_selected { |
| 254 | 264 | crate::theme::CatppuccinMocha::SURFACE0 |
| @@ -289,7 +299,7 @@ impl CodeView { | ||
| 289 | 299 | .response |
| 290 | 300 | .interact(egui::Sense::click()); |
| 291 | 301 | |
| 292 | - ui.ctx().data_mut(|d| d.insert_temp(row_id, r.hovered())); | |
| 302 | + crate::widgets::store_hover(ui.ctx(), row_id, r.hovered()); | |
| 293 | 303 | if r.clicked() { |
| 294 | 304 | clicked_file = Some(idx); |
| 295 | 305 | } |
| @@ -589,25 +599,52 @@ impl CodeView { | ||
| 589 | 599 | ); |
| 590 | 600 | ui.ctx().copy_text(code); |
| 591 | 601 | } |
| 592 | - if ui.button( | |
| 593 | - RichText::new(format!("{} Download", egui_phosphor::regular::DOWNLOAD_SIMPLE)) | |
| 602 | + let downloading = self.download_rx.is_some(); | |
| 603 | + if ui.add_enabled( | |
| 604 | + !downloading, | |
| 605 | + egui::Button::new( | |
| 606 | + RichText::new(format!("{} {}", egui_phosphor::regular::DOWNLOAD_SIMPLE, | |
| 607 | + if downloading { "Downloading..." } else { "Download" })) | |
| 608 | + ), | |
| 594 | 609 | ).clicked() { |
| 595 | - if let Some(dir) = rfd::FileDialog::new().pick_folder() { | |
| 596 | - let dl_lang = self.selected_language.generator(); | |
| 597 | - // Write all generated files | |
| 598 | - for file in &self.files { | |
| 599 | - let code = filter_hidden_fields_from_code(file, &self.hidden_fields); | |
| 600 | - let _ = std::fs::write(dir.join(&file.name), code); | |
| 601 | - } | |
| 602 | - // Generate mod file if the language supports it | |
| 603 | - let names: Vec<&str> = self.files.iter() | |
| 604 | - .filter(|f| f.name != "mod.rs") | |
| 605 | - .map(|f| f.name.as_str()) | |
| 606 | - .collect(); | |
| 607 | - if let Some(mod_code) = dl_lang.mod_file(&names) { | |
| 608 | - let _ = std::fs::write(dir.join("mod.rs"), mod_code); | |
| 609 | - } | |
| 610 | - } | |
| 610 | + // Prepare data for background thread | |
| 611 | + let file_data: Vec<(String, String)> = self.files.iter() | |
| 612 | + .map(|f| (f.name.clone(), filter_hidden_fields_from_code(f, &self.hidden_fields))) | |
| 613 | + .collect(); | |
| 614 | + let dl_lang = self.selected_language.generator(); | |
| 615 | + let names: Vec<String> = self.files.iter() | |
| 616 | + .filter(|f| f.name != "mod.rs") | |
| 617 | + .map(|f| f.name.clone()) | |
| 618 | + .collect(); | |
| 619 | + let mod_code = { | |
| 620 | + let name_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); | |
| 621 | + dl_lang.mod_file(&name_refs) | |
| 622 | + }; | |
| 623 | + | |
| 624 | + let (tx, rx) = std::sync::mpsc::channel(); | |
| 625 | + let ctx = ui.ctx().clone(); | |
| 626 | + std::thread::spawn(move || { | |
| 627 | + let result = (|| -> Result<usize, String> { | |
| 628 | + let dir = rfd::FileDialog::new() | |
| 629 | + .pick_folder() | |
| 630 | + .ok_or_else(|| "cancelled".to_string())?; | |
| 631 | + let mut count = 0; | |
| 632 | + for (name, code) in &file_data { | |
| 633 | + std::fs::write(dir.join(name), code) | |
| 634 | + .map_err(|e| format!("write {}: {}", name, e))?; | |
| 635 | + count += 1; | |
| 636 | + } | |
| 637 | + if let Some(mod_code) = &mod_code { | |
| 638 | + std::fs::write(dir.join("mod.rs"), mod_code) | |
| 639 | + .map_err(|e| format!("write mod.rs: {}", e))?; | |
| 640 | + count += 1; | |
| 641 | + } | |
| 642 | + Ok(count) | |
| 643 | + })(); | |
| 644 | + let _ = tx.send(result); | |
| 645 | + ctx.request_repaint(); | |
| 646 | + }); | |
| 647 | + self.download_rx = Some(rx); | |
| 611 | 648 | } |
| 612 | 649 | ui.label( |
| 613 | 650 | RichText::new(format!( |
+8-1src/views/schema_diagram.rs
| @@ -234,7 +234,14 @@ impl SchemaDiagramView { | ||
| 234 | 234 | let type_display = if let Some(ec) = enum_map.get(f.json_name.as_str()) { |
| 235 | 235 | ec.enum_name.clone() |
| 236 | 236 | } else { |
| 237 | - f.resolved_type.clone().unwrap_or_else(|| f.inferred_type.rust_type()) | |
| 237 | + match &f.resolved_type { | |
| 238 | + Some(rt) => { | |
| 239 | + // Use Rust syntax for display in diagram | |
| 240 | + let rust_lang = crate::lang::rust::RustGenerator; | |
| 241 | + rt.to_code(&rust_lang) | |
| 242 | + } | |
| 243 | + None => f.inferred_type.rust_type(), | |
| 244 | + } | |
| 238 | 245 | }; |
| 239 | 246 | let color = crate::theme::type_color(&type_display); |
| 240 | 247 | (f.json_name.clone(), type_display, color) |
+50-0src/widgets/cache_guard.rs
| @@ -0,0 +1,50 @@ | ||
| 1 | +use std::hash::{Hash, Hasher}; | |
| 2 | + | |
| 3 | +pub struct CacheGuard<T> { | |
| 4 | + key: u64, | |
| 5 | + value: T, | |
| 6 | +} | |
| 7 | + | |
| 8 | +impl<T> CacheGuard<T> { | |
| 9 | + pub fn new(initial: T) -> Self { | |
| 10 | + Self { key: 0, value: initial } | |
| 11 | + } | |
| 12 | + | |
| 13 | + /// Returns `Some(&mut T)` if the key changed (cache is dirty). | |
| 14 | + /// Returns `None` if cache is still valid. | |
| 15 | + pub fn get_if_changed(&mut self, new_key: u64) -> Option<&mut T> { | |
| 16 | + if new_key != self.key { | |
| 17 | + self.key = new_key; | |
| 18 | + Some(&mut self.value) | |
| 19 | + } else { | |
| 20 | + None | |
| 21 | + } | |
| 22 | + } | |
| 23 | + | |
| 24 | + /// Force the cache to be dirty on next check. | |
| 25 | + pub fn invalidate(&mut self) { | |
| 26 | + self.key = 0; | |
| 27 | + } | |
| 28 | + | |
| 29 | + pub fn value(&self) -> &T { | |
| 30 | + &self.value | |
| 31 | + } | |
| 32 | + | |
| 33 | + pub fn value_mut(&mut self) -> &mut T { | |
| 34 | + &mut self.value | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// Compute a hash key from a closure that hashes into a DefaultHasher. | |
| 39 | +pub fn hash_key(f: impl FnOnce(&mut std::collections::hash_map::DefaultHasher)) -> u64 { | |
| 40 | + let mut h = std::collections::hash_map::DefaultHasher::new(); | |
| 41 | + f(&mut h); | |
| 42 | + h.finish() | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// Convenience: hash a single hashable value. | |
| 46 | +pub fn hash_one<T: Hash>(val: &T) -> u64 { | |
| 47 | + let mut h = std::collections::hash_map::DefaultHasher::new(); | |
| 48 | + val.hash(&mut h); | |
| 49 | + h.finish() | |
| 50 | +} |
+20-0src/widgets/hover_row.rs
| @@ -0,0 +1,20 @@ | ||
| 1 | +/// Check if a row was hovered in the previous frame (via cached temp storage). | |
| 2 | +/// Returns (was_hovered, row_id) — use `store_hover` after rendering. | |
| 3 | +pub fn prev_frame_hover( | |
| 4 | + ctx: &egui::Context, | |
| 5 | + parent_id: egui::Id, | |
| 6 | + index: usize, | |
| 7 | +) -> (bool, egui::Id) { | |
| 8 | + check_hover(ctx, parent_id.with(("row", index))) | |
| 9 | +} | |
| 10 | + | |
| 11 | +/// Check hover for a pre-computed Id. | |
| 12 | +pub fn check_hover(ctx: &egui::Context, id: egui::Id) -> (bool, egui::Id) { | |
| 13 | + let hovered = ctx.data(|d| d.get_temp::<bool>(id)).unwrap_or(false); | |
| 14 | + (hovered, id) | |
| 15 | +} | |
| 16 | + | |
| 17 | +/// Store hover state for the next frame. | |
| 18 | +pub fn store_hover(ctx: &egui::Context, row_id: egui::Id, hovered: bool) { | |
| 19 | + ctx.data_mut(|d| d.insert_temp(row_id, hovered)); | |
| 20 | +} |
+98-0src/widgets/miller.rs
| @@ -0,0 +1,98 @@ | ||
| 1 | +use egui::Ui; | |
| 2 | + | |
| 3 | +/// Actions produced by miller column keyboard navigation. | |
| 4 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
| 5 | +pub enum MillerAction { | |
| 6 | + Down, | |
| 7 | + Up, | |
| 8 | + Enter, | |
| 9 | + Back, | |
| 10 | + GoTop, | |
| 11 | + GoBottom, | |
| 12 | + None, | |
| 13 | +} | |
| 14 | + | |
| 15 | +/// Read J/K/H/L/Arrow/Home/End/G keys and return the corresponding action. | |
| 16 | +/// Pass `skip: true` to suppress (e.g., when a text input has focus). | |
| 17 | +pub fn read_miller_keys(ui: &Ui, skip: bool) -> MillerAction { | |
| 18 | + if skip { | |
| 19 | + return MillerAction::None; | |
| 20 | + } | |
| 21 | + | |
| 22 | + let down = ui.input(|i| i.key_pressed(egui::Key::J) || i.key_pressed(egui::Key::ArrowDown)); | |
| 23 | + let up = ui.input(|i| i.key_pressed(egui::Key::K) || i.key_pressed(egui::Key::ArrowUp)); | |
| 24 | + let right = ui.input(|i| { | |
| 25 | + i.key_pressed(egui::Key::L) | |
| 26 | + || i.key_pressed(egui::Key::ArrowRight) | |
| 27 | + || i.key_pressed(egui::Key::Enter) | |
| 28 | + }); | |
| 29 | + let left = ui.input(|i| { | |
| 30 | + i.key_pressed(egui::Key::H) | |
| 31 | + || i.key_pressed(egui::Key::ArrowLeft) | |
| 32 | + || i.key_pressed(egui::Key::Backspace) | |
| 33 | + }); | |
| 34 | + let go_top = ui.input(|i| i.key_pressed(egui::Key::G) || i.key_pressed(egui::Key::Home)); | |
| 35 | + let go_bottom = ui.input(|i| i.key_pressed(egui::Key::End)); | |
| 36 | + | |
| 37 | + if down { | |
| 38 | + MillerAction::Down | |
| 39 | + } else if up { | |
| 40 | + MillerAction::Up | |
| 41 | + } else if right { | |
| 42 | + MillerAction::Enter | |
| 43 | + } else if left { | |
| 44 | + MillerAction::Back | |
| 45 | + } else if go_top { | |
| 46 | + MillerAction::GoTop | |
| 47 | + } else if go_bottom { | |
| 48 | + MillerAction::GoBottom | |
| 49 | + } else { | |
| 50 | + MillerAction::None | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// Apply a navigation action to a selection index. | |
| 55 | +/// Returns true if selection changed (caller should set scroll_to_selection). | |
| 56 | +pub fn apply_selection(selection: &mut usize, action: MillerAction, count: usize) -> bool { | |
| 57 | + match action { | |
| 58 | + MillerAction::Down => { | |
| 59 | + if count > 0 && *selection + 1 < count { | |
| 60 | + *selection += 1; | |
| 61 | + return true; | |
| 62 | + } | |
| 63 | + } | |
| 64 | + MillerAction::Up => { | |
| 65 | + if *selection > 0 { | |
| 66 | + *selection -= 1; | |
| 67 | + return true; | |
| 68 | + } | |
| 69 | + } | |
| 70 | + MillerAction::GoTop => { | |
| 71 | + if *selection != 0 { | |
| 72 | + *selection = 0; | |
| 73 | + return true; | |
| 74 | + } | |
| 75 | + } | |
| 76 | + MillerAction::GoBottom => { | |
| 77 | + if count > 0 && *selection != count - 1 { | |
| 78 | + *selection = count - 1; | |
| 79 | + return true; | |
| 80 | + } | |
| 81 | + } | |
| 82 | + _ => {} | |
| 83 | + } | |
| 84 | + false | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// Draw a vertical separator line (shared by both browser views). | |
| 88 | +pub fn draw_separator(ui: &mut Ui, height: f32) { | |
| 89 | + let sep_rect = ui.available_rect_before_wrap(); | |
| 90 | + ui.painter().line_segment( | |
| 91 | + [ | |
| 92 | + egui::pos2(sep_rect.left(), sep_rect.top()), | |
| 93 | + egui::pos2(sep_rect.left(), sep_rect.top() + height), | |
| 94 | + ], | |
| 95 | + egui::Stroke::new(1.0, crate::theme::CatppuccinMocha::SURFACE0), | |
| 96 | + ); | |
| 97 | + ui.add_space(4.0); | |
| 98 | +} |
+7-0src/widgets/mod.rs
| @@ -0,0 +1,7 @@ | ||
| 1 | +pub mod cache_guard; | |
| 2 | +pub mod hover_row; | |
| 3 | +pub mod miller; | |
| 4 | + | |
| 5 | +pub use cache_guard::{CacheGuard, hash_key}; | |
| 6 | +pub use hover_row::{prev_frame_hover, check_hover, store_hover}; | |
| 7 | +pub use miller::{MillerAction, read_miller_keys, apply_selection, draw_separator}; |