1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
// src/editor/hook/import_edit.rs
//
// EditorHook: the Import panel's actions. Adding resolves the typed path
// through `authoring::add::entry_from_path` -- the exact dispatch `cn add
// <file>` runs -- so a panel import and a CLI import produce identical
// entries; resolution failures (missing file, unknown extension, invalid
// content) land on the status line and nothing is committed. Browse fills the
// same field from a native picker, so both routes end at the same Add. Listed
// imports open in the standard edit form for full arg editing.
use super::*;
use std::path::Path;
impl EditorHook {
// The world's file-backed entries, in entry order.
pub(super) fn import_rows(&self) -> Vec<ImportRow> {
self.entries
.iter()
.enumerate()
.filter_map(|(i, e)| {
let ty = entry_type(e)?;
if !import_panel::IMPORT_TYPES.contains(&ty) {
return None;
}
let name = entry_name(e).unwrap_or("?");
let source = e
.get("args")
.map(import_panel::source_of)
.unwrap_or_default();
Some(ImportRow {
entry: i,
caption: format!("{name} ({ty}) {source}"),
})
})
.collect()
}
pub(super) fn make_import_view<'a>(
&'a self,
rows: &'a [ImportRow],
mouse: [f32; 2],
) -> ImportView<'a> {
ImportView {
rows,
scroll: self.import_scroll,
// Focus is asserted only while frontmost, matching the other
// panels' guard against fighting for typed keys.
focus: self.import_focus && self.panel_order.last() == Some(&PanelKey::Import),
status: self.import_status.as_ref(),
mouse,
}
}
pub(super) fn scroll_imports(&mut self, delta: f32) {
let rows_shown = import_panel::visible_rows(self.effective_size(PanelKey::Import)[1]);
let max = self.import_rows().len().saturating_sub(rows_shown);
self.import_scroll = scroll_step(self.import_scroll, delta, max);
}
// Route a resolved Import-panel click.
pub(super) fn apply_import_action(&mut self, action: ImportAction, world: &mut World) {
match action {
ImportAction::FocusPath => self.import_focus = true,
ImportAction::Browse => self.browse_import(world),
ImportAction::Add => self.add_import(world),
ImportAction::Open(i) => {
if let Some(row) = self.import_rows().get(i) {
self.open_import(row.entry, world);
}
}
// A click on panel chrome blurs the path field.
ImportAction::Consume => self.import_focus = false,
}
}
// Open the native file picker at the project directory and take what the
// user picks. The dialog blocks the frame loop while it is up (it is a
// modal OS window over the editor); cancelling leaves the field alone.
// This wrapper is the only untestable step -- what the pick feeds is all in
// `accept_browsed_path`.
pub(super) fn browse_import(&mut self, world: &mut World) {
let root = project_root();
if let Some(picked) = super::super::file_dialog::pick_import_file(&root) {
self.accept_browsed_path(world, &picked);
}
}
// Put a picked file's project-relative path in the field, ready to Add. Not
// added outright: the user still sees what resolved before committing.
pub(super) fn accept_browsed_path(&mut self, world: &mut World, picked: &Path) {
let text = super::super::file_dialog::project_path(picked, &project_root());
widget::seed_field(world, import_panel::PATH_INPUT, &text);
self.import_focus = true;
self.import_status = None;
}
// Resolve the typed path and append its entries (renamed to stay unique),
// exactly as `cn add <path>` would. Success clears the field; failure
// shows on the status line and commits nothing.
pub(super) fn add_import(&mut self, world: &mut World) {
self.import_status = None;
let path = widget::field_text(world, import_panel::PATH_INPUT)
.trim()
.to_string();
if path.is_empty() {
return;
}
// Scene entries defer reading their file to the cook, which would
// surface a typo only as a preview-rebuild log; catch it here instead.
if !std::path::Path::new(&path).is_file() {
let msg = format!("{path}: no such file");
self.console_sink.error(&format!("import: {msg}"));
self.import_status = Some(ImportStatus::Error(short_status(&msg)));
self.notifier.error_with(
&format!("Import failed: {msg}"),
notify::Action::OpenConsole,
);
return;
}
let mut new_entries = match crate::authoring::entry_from_path(&path) {
Ok(entries) => entries,
Err(e) => {
// The console keeps the full error; the status line clips it.
self.console_sink.error(&format!("import: {e}"));
self.import_status = Some(ImportStatus::Error(short_status(&e.to_string())));
self.notifier
.error_with(&format!("Import failed: {e}"), notify::Action::OpenConsole);
return;
}
};
// An `.hdr` into a world that already has a lighting environment
// retargets it rather than appending a second one the runtime would
// ignore, matching `cn add <file>`.
if let Some((name, source)) =
crate::authoring::try_retarget_environment_map(&mut self.entries, &new_entries)
{
let notice = format!("{name} now uses {source}");
self.import_status = Some(ImportStatus::Notice(short_status(¬ice)));
self.notifier.info(¬ice);
self.mark_changed();
widget::seed_field(world, import_panel::PATH_INPUT, "");
return;
}
let mut first_name = String::new();
for entry in &mut new_entries {
let base = entry_name(entry).unwrap_or("import").to_string();
let unique = self.unique_from(&base);
entry["name"] = serde_json::Value::String(unique.clone());
if first_name.is_empty() {
first_name = unique;
}
self.entries.push(entry.clone());
}
match new_entries.len() {
0 => {}
1 => self.notifier.success(&format!("Imported '{first_name}'")),
n => self.notifier.success(&format!("Imported {n} assets")),
}
self.mark_changed();
widget::seed_field(world, import_panel::PATH_INPUT, "");
}
// Open import entry `idx` in the standard add / edit form (part of the
// Assets UI, so the browse panel comes up alongside it).
pub(super) fn open_import(&mut self, idx: usize, world: &mut World) {
let Some(ty) = self.entries.get(idx).and_then(entry_type).map(String::from) else {
return;
};
self.panel_open = true;
self.open_form(world, ty, FormTarget::Entry(idx));
}
// Enter in the focused path field adds, like clicking the Add button.
pub(super) fn import_keys(&mut self, world: &mut World, input: &FrameInput) {
if self.import_focus && input.captured_key == Some(crate::components::InputKey::Enter) {
self.add_import(world);
}
}
}
// The directory imported paths are stored relative to, and where the picker
// opens: the process's working directory. A stored path carries a directory
// component, so the cook uses it verbatim and resolves it against that same
// working directory -- which is where the CLI roots the project, `assets/`
// included.
fn project_root() -> std::path::PathBuf {
std::env::current_dir().unwrap_or_default()
}