irongit

refactor: ui widgets

huncholanehuncholaneauthored
parent e5998b1commit 09b73598fd6c91d226796ff54b3ee4e68cbe51ffBrowse files

14 files changed, +482 -318

+78-69src/app.rs
@@ -53,19 +53,20 @@ pub struct JvApp {
5353 dropped_files: Vec<egui::DroppedFile>,
5454
5555 // FPS tracking
56- frame_times: Vec<f64>,
56+ frame_times: std::collections::VecDeque<f64>,
5757 fps_display: f64,
5858
5959 // 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>>>,
6161
6262 // Async sample fetch from GitHub
6363 samples_rx: Option<std::sync::mpsc::Receiver<Vec<SampleFile>>>,
6464 }
6565
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,
6970 }
7071
7172 /// Fetched sample file from GitHub
@@ -138,6 +139,50 @@ fn fetch_github_samples(
138139 });
139140 }
140141
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+
141186 impl JvApp {
142187 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
143188 // Load Phosphor icon font
@@ -187,7 +232,7 @@ impl JvApp {
187232 sidebar_width: 240.0,
188233 theme_applied: false,
189234 dropped_files: Vec::new(),
190- frame_times: Vec::with_capacity(60),
235+ frame_times: std::collections::VecDeque::with_capacity(60),
191236 fps_display: 0.0,
192237 file_dialog_rx: None,
193238 samples_rx,
@@ -233,46 +278,6 @@ impl JvApp {
233278 }
234279 }
235280
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-
276281 fn show_sidebar(&mut self, ui: &mut egui::Ui) {
277282 ui.vertical(|ui| {
278283 use egui_phosphor::regular;
@@ -369,7 +374,7 @@ impl JvApp {
369374 let ctx = ui.ctx().clone();
370375 std::thread::spawn(move || {
371376 if let Some(dir) = rfd::FileDialog::new().pick_folder() {
372- let _ = tx.send(FileDialogResult::Directory(dir));
377+ let _ = tx.send(read_directory_files(&dir));
373378 ctx.request_repaint();
374379 }
375380 });
@@ -385,7 +390,8 @@ impl JvApp {
385390 .add_filter("JSON & HAR", &["json", "har"])
386391 .pick_files()
387392 {
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);
389395 ctx.request_repaint();
390396 }
391397 });
@@ -452,8 +458,7 @@ impl JvApp {
452458 for (idx, filename, source) in &files {
453459 let is_disabled = self.disabled_files.contains(idx);
454460
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)));
457462
458463 let bg = if is_hovered {
459464 egui::Color32::from_rgba_premultiplied(40, 40, 55, 255)
@@ -489,8 +494,7 @@ impl JvApp {
489494 .show(ui, |ui| {
490495 ui.horizontal(|ui| {
491496 // 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)));
494498 let trash_color = if trash_hovered {
495499 CatppuccinMocha::RED
496500 } else {
@@ -501,7 +505,7 @@ impl JvApp {
501505 .color(trash_color)
502506 .size(12.0),
503507 );
504- ui.ctx().data_mut(|d| d.insert_temp(trash_id, rm.hovered()));
508+ jv::widgets::store_hover(ui.ctx(), trash_id, rm.hovered());
505509 trash_rect = rm.rect;
506510
507511 // Eye icon
@@ -549,7 +553,7 @@ impl JvApp {
549553 .response
550554 .interact(egui::Sense::click());
551555
552- ui.ctx().data_mut(|d| d.insert_temp(row_id, r.hovered()));
556+ jv::widgets::store_hover(ui.ctx(), row_id, r.hovered());
553557
554558 if r.clicked() {
555559 if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
@@ -927,11 +931,20 @@ impl JvApp {
927931 let files: Vec<egui::DroppedFile> = self.dropped_files.drain(..).collect();
928932 for file in files {
929933 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)
932936 } 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);
934946 }
947+ self.rebuild_schema();
935948 } else if let Some(bytes) = &file.bytes {
936949 let content = String::from_utf8_lossy(bytes).to_string();
937950 let name = file.name.clone();
@@ -956,21 +969,17 @@ impl eframe::App for JvApp {
956969 // Poll async file dialog results
957970 if let Some(rx) = &self.file_dialog_rx {
958971 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() {}
968976 }
977+ self.session_manager.update_session(&loaded.session);
969978 }
979+ self.rebuild_schema();
970980 self.file_dialog_rx = None;
971981 }
972982 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
973- // Dialog was cancelled
974983 self.file_dialog_rx = None;
975984 }
976985 Err(std::sync::mpsc::TryRecvError::Empty) => {}
@@ -1031,9 +1040,9 @@ impl eframe::App for JvApp {
10311040
10321041 // FPS tracking (rolling average of last 60 frames)
10331042 let dt = ctx.input(|i| i.stable_dt) as f64;
1034- self.frame_times.push(dt);
1043+ self.frame_times.push_back(dt);
10351044 if self.frame_times.len() > 60 {
1036- self.frame_times.remove(0);
1045+ self.frame_times.pop_front();
10371046 }
10381047 if !self.frame_times.is_empty() {
10391048 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 {
1212 pub fields: Vec<GeneratedField>,
1313 }
1414
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+
1589 #[derive(Debug, Clone)]
1690 pub struct GeneratedField {
1791 pub json_name: String,
1892 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>,
2295 pub needs_rename: bool,
2396 }
2497
@@ -81,14 +154,14 @@ impl CodeGenerator {
81154 serde_json::Value::Object(_) => {
82155 let child_name = to_pascal_case(key);
83156 Self::collect_structs(val, &child_name, structs, seen);
84- Some(child_name)
157+ Some(ResolvedType::Struct(child_name))
85158 }
86159 serde_json::Value::Array(arr) => {
87160 if let Some(first) = arr.first() {
88161 if first.is_object() {
89162 let child_name = to_pascal_case(&singularize(key));
90163 Self::collect_structs(first, &child_name, structs, seen);
91- Some(format!("Vec<{}>", child_name))
164+ Some(ResolvedType::Array(Box::new(ResolvedType::Struct(child_name))))
92165 } else {
93166 None
94167 }
@@ -109,15 +182,15 @@ impl CodeGenerator {
109182
110183 // Check if an existing struct with this name has identical fields
111184 if seen.contains(name) {
112- let field_sig: Vec<(&str, &InferredType, Option<&str>)> = fields
185+ let field_sig: Vec<(&str, &InferredType, Option<&ResolvedType>)> = fields
113186 .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()))
115188 .collect();
116189 let already_exists = structs.iter().any(|s| {
117190 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
119192 .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()))
121194 .collect();
122195 existing_sig == field_sig
123196 }
@@ -157,7 +230,7 @@ impl CodeGenerator {
157230 for field in &s.fields {
158231 let code_name = lang.field_name(&field.json_name);
159232 let type_str = match &field.resolved_type {
160- Some(rt) => localize_type(rt, lang),
233+ Some(rt) => rt.to_code(lang),
161234 None => lang.type_name(&field.inferred_type),
162235 };
163236 body.push_str(&lang.field_line(&code_name, &type_str, &field.json_name));
@@ -180,26 +253,6 @@ impl CodeGenerator {
180253 }
181254 }
182255
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-
203256 /// A generated project file (name + code content)
204257 pub struct GeneratedProjectFile {
205258 pub name: String,
@@ -342,16 +395,18 @@ pub fn generate_project(
342395 let mut field_pairs: Vec<(String, String)> = Vec::new();
343396 for field in fields {
344397 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+ }
347407 None => lang.type_name(&field.inferred_type),
348408 };
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));
355410 field_pairs.push((code_name, field.json_name.clone()));
356411 }
357412 code.push_str(&lang.struct_close(&field_pairs));
@@ -436,9 +491,9 @@ pub fn resolve_codegen_against_schema(
436491 for s in &gen.structs {
437492 for field in &s.fields {
438493 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());
442497 }
443498 }
444499 }
@@ -454,8 +509,8 @@ pub fn resolve_codegen_against_schema(
454509 let fields: Vec<GeneratedField> = ss.fields.iter().map(|(key, typ)| {
455510 let resolved = resolve_type_to_struct(typ, all_structs);
456511 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());
459514 }
460515 }
461516 GeneratedField {
@@ -484,27 +539,16 @@ fn merge_generated_fields(existing: &mut Vec<GeneratedField>, new_fields: &[Gene
484539 .collect();
485540
486541 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());
508552 }
509553 }
510554
@@ -515,64 +559,13 @@ fn merge_generated_fields(existing: &mut Vec<GeneratedField>, new_fields: &[Gene
515559 let mut field = new_field.clone();
516560 if !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) {
517561 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());
521563 }
522564 existing.push(field);
523565 }
524566 }
525567 }
526568
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-
576569 pub fn to_snake_case(s: &str) -> String {
577570 let mut result = String::new();
578571 for (i, ch) in s.chars().enumerate() {
@@ -679,21 +672,23 @@ pub fn first_normal_word(filename: &str) -> Option<String> {
679672 None
680673 }
681674
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.
683676 /// Recursively handles Vec<Object>, Option<Object>, Option<Vec<Object>>, etc.
684677 pub fn resolve_type_to_struct(
685678 typ: &InferredType,
686679 shared: &[crate::schema::SharedStruct],
687-) -> Option<String> {
680+) -> Option<ResolvedType> {
688681 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+ }
690685 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)))
693688 }
694689 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)))
697692 }
698693 _ => None,
699694 }
+2-0src/lang/mod.rs
@@ -37,6 +37,8 @@ pub trait LanguageGenerator {
3737 fn enum_close(&self) -> String;
3838 fn enum_variant(&self, variant_name: &str, json_value: &str) -> String;
3939 fn type_name(&self, inferred: &InferredType) -> String;
40+ fn wrap_array(&self, inner: &str) -> String;
41+ fn wrap_optional(&self, inner: &str) -> String;
4042 fn mod_file(&self, file_names: &[&str]) -> Option<String>;
4143 fn sanitize_keyword(&self, name: &str) -> String;
4244 fn field_name(&self, json_name: &str) -> String;
+8-0src/lang/rust.rs
@@ -100,6 +100,14 @@ impl LanguageGenerator for RustGenerator {
100100 }
101101 }
102102
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+
103111 fn mod_file(&self, file_names: &[&str]) -> Option<String> {
104112 let mut out = String::new();
105113 for name in file_names {
+8-0src/lang/swift.rs
@@ -151,6 +151,14 @@ impl LanguageGenerator for SwiftGenerator {
151151 }
152152 }
153153
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+
154162 fn mod_file(&self, _file_names: &[&str]) -> Option<String> {
155163 None
156164 }
+1-0src/lib.rs
@@ -8,3 +8,4 @@ pub mod temporal;
88 pub mod theme;
99 pub mod types;
1010 pub mod views;
11+pub mod widgets;
+7-52src/views/browser.rs
@@ -200,56 +200,21 @@ impl BrowserView {
200200 });
201201
202202 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()) {
225205 self.scroll_to_selection = true;
226206 self.sync_jq_from_path();
227207 }
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 {
234209 if let Some(entry) = current_entries.get(self.selection) {
235210 if entry.is_container {
236211 self.enter_selected(current, &current_entries);
237212 }
238213 }
239214 }
240- if left && !self.path.is_empty() {
215+ if action == crate::widgets::MillerAction::Back && !self.path.is_empty() {
241216 self.go_up();
242217 }
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- }
253218
254219 // Copy selected value: c or Ctrl+C
255220 let copy = ui.input(|i| i.key_pressed(egui::Key::C))
@@ -529,15 +494,7 @@ impl BrowserView {
529494 }
530495
531496 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);
541498 }
542499
543500 fn render_parent_column(
@@ -641,9 +598,7 @@ impl BrowserView {
641598 let entry = &entries[i];
642599 let is_selected = i == selection;
643600
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);
647602
648603 let bg = if is_selected {
649604 CatppuccinMocha::SURFACE0
@@ -758,7 +713,7 @@ impl BrowserView {
758713 });
759714
760715 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());
762717
763718 if response.clicked() && !copy_clicked {
764719 clicked = Some(i);
+68-31src/views/code.rs
@@ -76,6 +76,8 @@ pub struct CodeView {
7676 enum_popup: EnumPopup,
7777 // Selected code language
7878 selected_language: crate::lang::CodeLanguage,
79+ // Async download state
80+ download_rx: Option<std::sync::mpsc::Receiver<Result<usize, String>>>,
7981 }
8082
8183 impl CodeView {
@@ -96,6 +98,7 @@ impl CodeView {
9698 hidden_fields: BTreeSet::new(),
9799 enum_popup: EnumPopup::new(),
98100 selected_language: crate::lang::CodeLanguage::Rust,
101+ download_rx: None,
99102 }
100103 }
101104
@@ -149,20 +152,28 @@ impl CodeView {
149152 // Rebuild hidden_fields from session state
150153 self.hidden_fields = hidden_fields.iter().cloned().collect();
151154
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+
152165 let enum_count = enum_conversions.len();
153166 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);
158170 for (name, _) in parsed_files {
159- name.hash(&mut h);
171+ name.hash(h);
160172 }
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+ });
166177 if self.cache_key != key {
167178 self.rebuild_file_mode(parsed_files, schema);
168179 self.build_struct_index();
@@ -247,8 +258,7 @@ impl CodeView {
247258 prev_was_group = file.is_group;
248259
249260 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)));
252262
253263 let bg = if is_selected {
254264 crate::theme::CatppuccinMocha::SURFACE0
@@ -289,7 +299,7 @@ impl CodeView {
289299 .response
290300 .interact(egui::Sense::click());
291301
292- ui.ctx().data_mut(|d| d.insert_temp(row_id, r.hovered()));
302+ crate::widgets::store_hover(ui.ctx(), row_id, r.hovered());
293303 if r.clicked() {
294304 clicked_file = Some(idx);
295305 }
@@ -589,25 +599,52 @@ impl CodeView {
589599 );
590600 ui.ctx().copy_text(code);
591601 }
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+ ),
594609 ).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);
611648 }
612649 ui.label(
613650 RichText::new(format!(
+8-1src/views/schema_diagram.rs
@@ -234,7 +234,14 @@ impl SchemaDiagramView {
234234 let type_display = if let Some(ec) = enum_map.get(f.json_name.as_str()) {
235235 ec.enum_name.clone()
236236 } 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+ }
238245 };
239246 let color = crate::theme::type_color(&type_display);
240247 (f.json_name.clone(), type_display, color)
+9-42src/views/shared_browser.rs
@@ -82,32 +82,11 @@ impl SharedBrowserView {
8282 }
8383
8484 // Keyboard navigation
85- let down = ui.input(|i| {
86- i.key_pressed(egui::Key::J) || i.key_pressed(egui::Key::ArrowDown)
87- });
88- let up = ui.input(|i| {
89- i.key_pressed(egui::Key::K) || i.key_pressed(egui::Key::ArrowUp)
90- });
91- let right = ui.input(|i| {
92- i.key_pressed(egui::Key::L)
93- || i.key_pressed(egui::Key::ArrowRight)
94- || i.key_pressed(egui::Key::Enter)
95- });
96- let left = ui.input(|i| {
97- i.key_pressed(egui::Key::H)
98- || i.key_pressed(egui::Key::ArrowLeft)
99- || i.key_pressed(egui::Key::Backspace)
100- });
101-
102- if down && self.selection + 1 < current_entries.len() {
103- self.selection += 1;
104- self.scroll_to_selection = true;
105- }
106- if up && self.selection > 0 {
107- self.selection -= 1;
85+ let action = crate::widgets::read_miller_keys(ui, false);
86+ if crate::widgets::apply_selection(&mut self.selection, action, current_entries.len()) {
10887 self.scroll_to_selection = true;
10988 }
110- if right {
89+ if action == crate::widgets::MillerAction::Enter {
11190 if let Some(entry) = current_entries.get(self.selection) {
11291 if entry.is_container {
11392 self.path.push(entry.label.clone());
@@ -116,7 +95,7 @@ impl SharedBrowserView {
11695 }
11796 }
11897 }
119- if left && !self.path.is_empty() {
98+ if action == crate::widgets::MillerAction::Back && !self.path.is_empty() {
12099 let popped = self.path.pop().unwrap();
121100 self.restore_key = Some(popped);
122101 self.scroll_to_selection = true;
@@ -368,15 +347,7 @@ fn is_navigable_type(typ: &InferredType, all_structs: &[SharedStruct]) -> bool {
368347 }
369348
370349 fn draw_separator(ui: &mut Ui, height: f32) {
371- let sep_rect = ui.available_rect_before_wrap();
372- ui.painter().line_segment(
373- [
374- egui::pos2(sep_rect.left(), sep_rect.top()),
375- egui::pos2(sep_rect.left(), sep_rect.top() + height),
376- ],
377- egui::Stroke::new(1.0, CatppuccinMocha::SURFACE0),
378- );
379- ui.add_space(4.0);
350+ crate::widgets::draw_separator(ui, height);
380351 }
381352
382353 /// Compute titles for each of the three panes based on the current navigation path.
@@ -504,9 +475,7 @@ fn render_current_column(
504475 let entry = &entries[i];
505476 let is_selected = i == selection;
506477
507- // Check hover from previous frame via temp storage
508- let row_id = ui.id().with(("row", i));
509- let is_hovered = ui.ctx().data(|d| d.get_temp::<bool>(row_id)).unwrap_or(false);
478+ let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i);
510479
511480 let bg = if is_selected {
512481 CatppuccinMocha::SURFACE0
@@ -575,7 +544,7 @@ fn render_current_column(
575544 .interact(egui::Sense::click());
576545
577546 // Store hover state for next frame
578- ui.ctx().data_mut(|d| d.insert_temp(row_id, r.hovered()));
547+ crate::widgets::store_hover(ui.ctx(), row_id, r.hovered());
579548
580549 if scroll_to_selection && is_selected {
581550 r.scroll_to_me(Some(egui::Align::Center));
@@ -689,9 +658,7 @@ fn render_values_column(
689658 let color = crate::theme::type_color(&short);
690659 let is_nav = is_navigable_type(typ, &all);
691660
692- // Hover state from previous frame
693- let row_id = ui.id().with(("val_row", idx));
694- let is_hovered = ui.ctx().data(|d| d.get_temp::<bool>(row_id)).unwrap_or(false);
661+ let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), idx);
695662
696663 let bg = if is_hovered {
697664 egui::Color32::from_rgba_unmultiplied(
@@ -748,7 +715,7 @@ fn render_values_column(
748715 })
749716 .response;
750717
751- ui.ctx().data_mut(|d| d.insert_temp(row_id, row_r.hovered()));
718+ crate::widgets::store_hover(ui.ctx(), row_id, row_r.hovered());
752719 }
753720 });
754721 }
+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};