Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.

Commit 9a134be

Browse files
committed
Match the original property widgets
Port the per-type widget behaviors from the old src/widgets/ files: - Numbers (int/float): paired input + range slider like number.js buildSliderInput. The slider window derives from a range anchor (half = |anchor|*10 + 10, capped at 10M for ints / 1M for floats), frozen while scrubbing so dragging to the edge cannot grow the range; re-anchors on manual edits, external changes (undo/redo) and scrub release. Scrubs coalesce into a single undo entry. - Colors: swatch + R/G/B/A channel badges with 0-255 clamped inputs (color.js), now also triggered by fields the game schema declares as Color, not just *color* key names. - Vectors: X/Y/Z/W axis badges on inline components (vec.js); expanding the array gives full slider+input rows per component. - resource_name/soundevent/panorama: text field plus a browse button with the original per-kind file filters from resource.js (sound: vsndevts/vsndstck/wav/mp3, panorama: images/layouts, resource: vmdl/vpcf/vnmskel/vmat). Dialogs run on a worker thread; picked paths are made document-relative with forward slashes, mirroring the old Electron picker's relativeTo behavior. https://claude.ai/code/session_01N8MUF11rARS9ZhHPwLtPmh
1 parent cba9bc7 commit 9a134be

5 files changed

Lines changed: 252 additions & 18 deletions

File tree

crates/vdata-editor/src/doc.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ pub struct TreeView {
122122
pub renaming: Option<(NodeId, String)>,
123123
/// Row index to scroll to on the next frame.
124124
pub scroll_to_row: Option<usize>,
125+
/// Resource browse dialog in flight: target node, typed kind, receiver.
126+
pub pending_pick: Option<(
127+
NodeId,
128+
String,
129+
std::sync::mpsc::Receiver<Option<std::path::PathBuf>>,
130+
)>,
125131
}
126132

127133
impl TreeView {

crates/vdata-editor/src/ui/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pub mod status;
55
pub mod text;
66
pub mod theme;
77
pub mod tree;
8+
pub mod widgets;

crates/vdata-editor/src/ui/tree.rs

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::schema::SchemaDb;
1515

1616
use super::icons;
1717
use super::theme::Palette;
18+
use super::widgets;
1819

1920
pub const ROW_HEIGHT: f32 = 22.0;
2021

@@ -170,6 +171,27 @@ pub fn show(
170171
) {
171172
let mut actions: Vec<TreeAction> = Vec::new();
172173

174+
// Resolve a finished resource-browse dialog into an edit.
175+
if let Some((id, kind, rx)) = doc.tree_view.pending_pick.take() {
176+
match rx.try_recv() {
177+
Ok(Some(path)) => {
178+
let base = doc.file_path.as_deref().and_then(|p| p.parent());
179+
let value = widgets::relativize_path(&path, base);
180+
actions.push(TreeAction::SetScalar {
181+
id,
182+
new: Scalar::Typed { kind, value },
183+
label: "Pick resource",
184+
});
185+
}
186+
Ok(None) => {} // dialog cancelled
187+
Err(std::sync::mpsc::TryRecvError::Empty) => {
188+
doc.tree_view.pending_pick = Some((id, kind, rx));
189+
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
190+
}
191+
Err(std::sync::mpsc::TryRecvError::Disconnected) => {}
192+
}
193+
}
194+
173195
// ---- Toolbar ----
174196
ui.horizontal(|ui| {
175197
ui.add_space(2.0);
@@ -658,19 +680,17 @@ fn value_ui(
658680
}
659681
}
660682
Payload::Scalar(Scalar::Int(i)) => {
661-
let mut v = i;
662-
if ui.add(DragValue::new(&mut v).speed(1)).changed() {
683+
// Paired input + adaptive-range slider, like the old number.js.
684+
if let Some(v) = widgets::slider_input(ui, ("num", id), i as f64, true) {
663685
actions.push(TreeAction::SetScalar {
664686
id,
665-
new: Scalar::Int(v),
687+
new: Scalar::Int(v as i64),
666688
label: "Edit number",
667689
});
668690
}
669691
}
670692
Payload::Scalar(Scalar::Double(d)) => {
671-
let mut v = d;
672-
let speed = (v.abs() * 0.01).clamp(0.01, 10.0);
673-
if ui.add(DragValue::new(&mut v).speed(speed)).changed() {
693+
if let Some(v) = widgets::slider_input(ui, ("num", id), d, false) {
674694
actions.push(TreeAction::SetScalar {
675695
id,
676696
new: Scalar::Double(v),
@@ -683,14 +703,40 @@ fn value_ui(
683703
}
684704
Payload::Scalar(Scalar::Typed { kind, value }) => {
685705
ui.label(RichText::new(format!("{kind}:")).color(palette.typed).small());
686-
let avail = ui.available_width() - 8.0;
706+
let avail = ui.available_width() - 34.0;
687707
if let Some(new) = editable_text(ui, ("typed", id), &value, avail, None) {
688708
actions.push(TreeAction::SetScalar {
689709
id,
690-
new: Scalar::Typed { kind, value: new },
710+
new: Scalar::Typed {
711+
kind: kind.clone(),
712+
value: new,
713+
},
691714
label: "Edit value",
692715
});
693716
}
717+
// Browse button with the original per-kind file filters.
718+
let glyph = widgets::resource_button_glyph(&kind);
719+
let browse = ui.small_button(glyph).on_hover_text(match kind.as_str() {
720+
"soundevent" => "Sound event",
721+
"panorama" => "Panorama path",
722+
_ => "Resource path",
723+
});
724+
if browse.clicked() && doc.tree_view.pending_pick.is_none() {
725+
let (filter_name, extensions) = widgets::resource_filters(&kind);
726+
let dir = doc
727+
.file_path
728+
.as_deref()
729+
.and_then(|p| p.parent().map(std::path::Path::to_path_buf));
730+
let (tx, rx) = std::sync::mpsc::channel();
731+
doc.tree_view.pending_pick = Some((id, kind, rx));
732+
std::thread::spawn(move || {
733+
let mut dialog = rfd::FileDialog::new().add_filter(filter_name, extensions);
734+
if let Some(dir) = dir {
735+
dialog = dialog.set_directory(dir);
736+
}
737+
let _ = tx.send(dialog.pick_file());
738+
});
739+
}
694740
}
695741
Payload::Scalar(Scalar::Comment(text)) => {
696742
let avail = ui.available_width() - 8.0;
@@ -708,7 +754,7 @@ fn value_ui(
708754
ui.label(RichText::new("null").color(palette.badge_null).italics());
709755
}
710756
Payload::Array(children) => {
711-
if !inline_vector_ui(ui, doc, palette, id, &children, actions) {
757+
if !inline_vector_ui(ui, doc, schema, palette, id, &children, actions) {
712758
container_summary_ui(ui, doc, palette, id, false);
713759
}
714760
}
@@ -844,9 +890,11 @@ fn string_value_ui(
844890

845891
/// Inline editors for short numeric arrays (vectors and colors).
846892
/// Returns false when the array should render as a normal container row.
893+
#[allow(clippy::too_many_arguments)]
847894
fn inline_vector_ui(
848895
ui: &mut egui::Ui,
849896
doc: &mut Document,
897+
schema: Option<&SchemaDb>,
850898
palette: &Palette,
851899
id: NodeId,
852900
children: &[NodeId],
@@ -863,9 +911,13 @@ fn inline_vector_ui(
863911
}
864912
}
865913

866-
// Color swatch for [r, g, b] / [r, g, b, a] byte arrays under *color* keys.
914+
// Color swatch for [r, g, b] / [r, g, b, a] byte arrays under *color*
915+
// keys, or fields the game schema declares as `Color`.
867916
let key = &doc.model.node(id).key;
868-
let is_colorish = ci_contains(key, "color")
917+
let schema_says_color = schema
918+
.and_then(|db| db.field_widgets.get(key))
919+
.is_some_and(|hint| *hint == crate::schema::WidgetHint::Color);
920+
let is_colorish = (ci_contains(key, "color") || schema_says_color)
869921
&& children.len() >= 3
870922
&& values
871923
.iter()
@@ -896,26 +948,33 @@ fn inline_vector_ui(
896948
}
897949
}
898950

899-
const AXES: [&str; 4] = ["x", "y", "z", "w"];
951+
// Per-component editors with the original axis/channel badges
952+
// (X/Y/Z/W from vec.js, R/G/B/A from color.js). Expanding the array
953+
// exposes full slider+input rows per component.
954+
const AXES: [&str; 4] = ["X", "Y", "Z", "W"];
955+
const CHANNELS: [&str; 4] = ["R", "G", "B", "A"];
956+
let labels = if is_colorish { &CHANNELS } else { &AXES };
900957
for (i, &child) in children.iter().enumerate() {
901-
if !is_colorish {
902-
ui.label(RichText::new(AXES[i]).color(palette.dim).small());
903-
}
958+
ui.label(RichText::new(labels[i]).color(palette.dim).small());
904959
match values[i] {
905960
Scalar::Int(v) => {
906961
let mut v = v;
907-
if ui.add(DragValue::new(&mut v).speed(1)).changed() {
962+
let mut drag = DragValue::new(&mut v).speed(1);
963+
if is_colorish {
964+
drag = drag.range(0..=255);
965+
}
966+
if ui.add(drag).changed() {
908967
actions.push(TreeAction::SetScalar {
909968
id: child,
910969
new: Scalar::Int(v),
911-
label: "Edit vector",
970+
label: if is_colorish { "Edit color" } else { "Edit vector" },
912971
});
913972
}
914973
}
915974
Scalar::Double(v) => {
916975
let mut v = v;
917976
let speed = (v.abs() * 0.01).clamp(0.01, 10.0);
918-
if ui.add(DragValue::new(&mut v).speed(speed)).changed() {
977+
if ui.add(DragValue::new(&mut v).speed(speed).max_decimals(6)).changed() {
919978
actions.push(TreeAction::SetScalar {
920979
id: child,
921980
new: Scalar::Double(v),
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//! Property widgets ported from the original `src/widgets/` implementations.
2+
//!
3+
//! The defining widget of the old editor is the paired **slider + input**
4+
//! (`number.js` `buildSliderInput`): a numeric text field next to a range
5+
//! slider whose bounds derive from a *range anchor* — `half = |anchor|·10+10`,
6+
//! capped (10 000 000 for ints, 1 000 000 for floats). The anchor is frozen
7+
//! while scrubbing so dragging to the edge cannot blow the range up, and
8+
//! re-anchors on manual edits, external changes (undo), or scrub release.
9+
10+
use egui::{DragValue, Slider};
11+
12+
/// Cap on the symmetric slider half-range, same constants as `number.js`.
13+
const INT_HALF_LIMIT: f64 = 10_000_000.0;
14+
const FLOAT_HALF_LIMIT: f64 = 1_000_000.0;
15+
16+
#[derive(Clone, Copy, PartialEq)]
17+
struct SliderAnchor {
18+
anchor: f64,
19+
last_seen: f64,
20+
}
21+
22+
/// Symmetric slider window for a given anchor value.
23+
pub fn slider_bounds(anchor: f64, is_int: bool) -> (f64, f64) {
24+
let cap = if is_int { INT_HALF_LIMIT } else { FLOAT_HALF_LIMIT };
25+
let mag = if anchor.is_finite() { anchor.abs() } else { 0.0 };
26+
let mut half = (mag * 10.0 + 10.0).min(cap);
27+
if mag > half {
28+
half = cap;
29+
}
30+
if is_int {
31+
(-half.floor(), half.ceil())
32+
} else {
33+
(-half, half)
34+
}
35+
}
36+
37+
/// Paired numeric input + adaptive-range slider. Returns the new value when
38+
/// either control changed it this frame.
39+
pub fn slider_input(ui: &mut egui::Ui, salt: (&str, u32), current: f64, is_int: bool) -> Option<f64> {
40+
let id = egui::Id::new(("slider-input", salt));
41+
let mut state: SliderAnchor = ui
42+
.data(|d| d.get_temp(id))
43+
.unwrap_or(SliderAnchor {
44+
anchor: current,
45+
last_seen: current,
46+
});
47+
48+
// External change (undo/redo, text-pane apply): re-anchor, like the old
49+
// widget's __vdeSetValueFromModel hook.
50+
if state.last_seen != current {
51+
state.anchor = current;
52+
}
53+
54+
let mut value = current;
55+
let mut changed = false;
56+
57+
// Manual input: commits like a DragValue (type + Enter / drag), and
58+
// re-anchors the slider window.
59+
let drag = if is_int {
60+
DragValue::new(&mut value).speed(1)
61+
} else {
62+
DragValue::new(&mut value).speed(0.01).max_decimals(6)
63+
};
64+
if ui.add_sized([56.0, 18.0], drag).changed() {
65+
if is_int {
66+
value = value.round();
67+
}
68+
state.anchor = value;
69+
changed = true;
70+
}
71+
72+
// Range slider over the anchored window; the window is frozen during the
73+
// drag so scrubbing to the edge can't grow it.
74+
let (min_b, max_b) = slider_bounds(state.anchor, is_int);
75+
let slider_width = (ui.available_width() - 8.0).max(40.0);
76+
let mut slider_value = value.clamp(min_b, max_b);
77+
ui.spacing_mut().slider_width = slider_width;
78+
let slider = if is_int {
79+
Slider::new(&mut slider_value, min_b..=max_b)
80+
.show_value(false)
81+
.step_by(1.0)
82+
} else {
83+
Slider::new(&mut slider_value, min_b..=max_b).show_value(false)
84+
};
85+
let resp = ui.add(slider);
86+
if resp.changed() {
87+
value = if is_int { slider_value.round() } else { slider_value };
88+
changed = true;
89+
}
90+
if resp.drag_stopped() {
91+
// Re-anchor on release so the next scrub gets a window around the
92+
// new value (the old widget kept the stale anchor until a manual
93+
// edit, which its own comments called out as confusing).
94+
state.anchor = value;
95+
}
96+
97+
state.last_seen = if changed { value } else { current };
98+
ui.data_mut(|d| d.insert_temp(id, state));
99+
100+
changed.then_some(value)
101+
}
102+
103+
/// File-dialog filters per typed-string kind, same lists as `resource.js`.
104+
pub fn resource_filters(kind: &str) -> (&'static str, &'static [&'static str]) {
105+
match kind {
106+
"soundevent" => ("Sound", &["vsndevts", "vsndstck", "wav", "mp3"]),
107+
"panorama" => (
108+
"Images / layouts",
109+
&["png", "jpg", "jpeg", "psd", "svg", "vgui", "xml"],
110+
),
111+
_ => (
112+
"Models / particles / materials",
113+
&["vmdl", "vpcf", "vnmskel", "vmat"],
114+
),
115+
}
116+
}
117+
118+
/// Browse-button glyph per kind (`🔊` for sound events, `📁` otherwise).
119+
pub fn resource_button_glyph(kind: &str) -> &'static str {
120+
if kind == "soundevent" { "🔊" } else { "📁" }
121+
}
122+
123+
/// Make a picked path doc-relative when possible and forward-slashed,
124+
/// mirroring the `relativeTo: baseDir` behaviour of the Electron picker.
125+
pub fn relativize_path(picked: &std::path::Path, base_dir: Option<&std::path::Path>) -> String {
126+
let path = base_dir
127+
.and_then(|base| picked.strip_prefix(base).ok())
128+
.unwrap_or(picked);
129+
path.to_string_lossy().replace('\\', "/")
130+
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::*;
135+
136+
#[test]
137+
fn bounds_follow_the_original_formula() {
138+
// value 1 → half-range 20 → [-20, 20] (the example from number.js)
139+
assert_eq!(slider_bounds(1.0, false), (-20.0, 20.0));
140+
assert_eq!(slider_bounds(0.0, false), (-10.0, 10.0));
141+
assert_eq!(slider_bounds(-5.0, true), (-60.0, 60.0));
142+
// Caps: 10M for ints, 1M for floats.
143+
assert_eq!(slider_bounds(5e7, true), (-INT_HALF_LIMIT, INT_HALF_LIMIT));
144+
assert_eq!(
145+
slider_bounds(5e6, false),
146+
(-FLOAT_HALF_LIMIT, FLOAT_HALF_LIMIT)
147+
);
148+
}
149+
150+
#[test]
151+
fn filters_match_resource_js() {
152+
assert_eq!(resource_filters("soundevent").1, &["vsndevts", "vsndstck", "wav", "mp3"]);
153+
assert_eq!(resource_filters("panorama").0, "Images / layouts");
154+
assert_eq!(resource_filters("resource_name").1[0], "vmdl");
155+
assert_eq!(resource_button_glyph("soundevent"), "🔊");
156+
assert_eq!(resource_button_glyph("resource_name"), "📁");
157+
}
158+
159+
#[test]
160+
fn relativize_strips_doc_dir_and_normalizes_slashes() {
161+
let base = std::path::Path::new("/game/scripts");
162+
let picked = std::path::Path::new("/game/scripts/models/a.vmdl");
163+
assert_eq!(relativize_path(picked, Some(base)), "models/a.vmdl");
164+
let outside = std::path::Path::new("/elsewhere/b.vmdl");
165+
assert_eq!(relativize_path(outside, Some(base)), "/elsewhere/b.vmdl");
166+
assert_eq!(relativize_path(outside, None), "/elsewhere/b.vmdl");
167+
}
168+
}

readme/screenshot.png

13.3 KB
Loading

0 commit comments

Comments
 (0)