1pub mod app;
6pub mod autocut_ui;
7pub mod capture_ui;
8pub mod curves;
9pub mod effects_ui;
10pub mod export_ui;
11pub mod frame_ui;
12pub mod import_ui;
13pub mod inspector;
14pub mod layout;
15pub mod library;
16pub mod markers_ui;
17pub mod mixer_ui;
18pub mod nodes;
19pub mod paste_ui;
20pub mod planner;
21pub mod presets_ui;
22pub mod preview;
23pub mod retime;
24pub mod settings_ui;
25pub mod shader_ui;
26pub mod subtitles_ui;
27pub mod timeline;
28pub mod tools;
29pub mod tracking_ui;
30pub mod transitions_ui;
31
32use crate::model::{Animated, Id, Mask, MaskShape, Project, LABEL_COLORS};
33use crate::theme::Palette;
34use eframe::egui::{self, Button, DragValue, Grid, Response};
35
36pub(crate) fn edit_start(r: &Response) -> bool {
38 r.drag_started() || (r.changed() && !r.dragged())
39}
40
41#[derive(Default)]
44pub(crate) struct Gesture {
45 pub start: bool,
46 pub changed: bool,
47}
48
49impl Gesture {
50 pub fn note(&mut self, r: &Response) {
51 self.start |= edit_start(r);
52 self.changed |= r.changed();
53 }
54 pub fn note_text(&mut self, r: &Response) {
57 self.start |= r.gained_focus();
58 self.changed |= r.changed();
59 }
60 pub fn click(&mut self) {
61 self.start = true;
62 self.changed = true;
63 }
64}
65
66pub(crate) fn once(flag: &mut bool, undo: &mut dyn FnMut(&Project), p: &Project) {
68 if !*flag {
69 undo(p);
70 *flag = true;
71 }
72}
73
74pub(crate) fn key_buttons(ui: &mut egui::Ui, a: &mut Animated, lt: f64, palette: &Palette, g: &mut Gesture) {
76 let id = ui.id().with(("kf", a as *const _ as usize));
77 if crate::ui::tools::icon_button(
78 ui,
79 palette,
80 id,
81 crate::ui::tools::Glyph::Diamond,
82 "Toggle keyframe at playhead",
83 a.has_key_at(lt),
84 )
85 .clicked()
86 {
87 a.toggle_key(lt);
88 g.click();
89 }
90 if a.is_animated() {
91 if crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::Cross, "keys").clicked() {
92 a.clear_keys(lt);
93 g.click();
94 }
95 ui.label(format!("{} keys", a.keys.len()));
96 }
97}
98
99pub(crate) fn seed_mask_points(m: &mut Mask) {
103 if !matches!(m.shape, MaskShape::Polygon | MaskShape::Path) || m.points.len() >= 3 {
104 return;
105 }
106 let (cx, cy) = (m.cx.value as f32, m.cy.value as f32);
107 let (rx, ry) = ((m.rx.value as f32).abs().max(1.0), (m.ry.value as f32).abs().max(1.0));
108 m.points = vec![(cx - rx, cy - ry), (cx + rx, cy - ry), (cx + rx, cy + ry), (cx - rx, cy + ry)];
109}
110
111pub(crate) fn mask_grid(ui: &mut egui::Ui, m: &mut Mask, lt: f64, palette: &Palette, g: &mut Gesture, salt: egui::Id) {
114 Grid::new(salt).num_columns(2).show(ui, |ui| {
115 ui.label("Shape");
116 egui::ComboBox::from_id_salt(salt.with("shape")).selected_text(m.shape.name()).show_ui(ui, |ui| {
117 for sh in MaskShape::ALL {
118 g.note(&ui.selectable_value(&mut m.shape, sh, sh.name()));
119 }
120 });
121 seed_mask_points(m);
122 ui.end_row();
123 ui.label("Enabled");
124 g.note(&ui.checkbox(&mut m.enabled, ""));
125 ui.end_row();
126 ui.label("Invert");
127 g.note(&ui.checkbox(&mut m.invert, ""));
128 ui.end_row();
129 let rows: [(&str, f64, f64, f64); 8] = [
132 ("X", -10000.0, 10000.0, 0.5),
133 ("Y", -10000.0, 10000.0, 0.5),
134 ("Radius X", 0.0, 10000.0, 0.5),
135 ("Radius Y", 0.0, 10000.0, 0.5),
136 ("Rotation", -360.0, 360.0, 0.5),
137 ("Feather", 0.0, 500.0, 0.5),
138 ("Expand", -500.0, 500.0, 0.5),
139 ("Opacity", 0.0, 1.0, 0.01),
140 ];
141 for (label, lo, hi, speed) in rows {
142 ui.label(label);
143 ui.horizontal(|ui| {
144 let a: &mut Animated = match label {
145 "X" => &mut m.cx,
146 "Y" => &mut m.cy,
147 "Radius X" => &mut m.rx,
148 "Radius Y" => &mut m.ry,
149 "Rotation" => &mut m.rotation,
150 "Feather" => &mut m.feather,
151 "Expand" => &mut m.expand,
152 _ => &mut m.opacity,
153 };
154 let mut v = a.at(lt);
155 let r = ui.add(DragValue::new(&mut v).range(lo..=hi).speed(speed).clamp_existing_to_range(false));
156 if r.changed() {
157 a.set_at(lt, v);
158 }
159 g.note(&r);
160 key_buttons(ui, a, lt, palette, g);
161 });
162 ui.end_row();
163 }
164 });
165}
166
167#[derive(Clone, Debug)]
169pub enum DragPayload {
170 Asset(Id),
172 Path(String),
174 Sequence(Id),
176 Template(String),
178 Effect(crate::model::EffectKind),
180 Transition(crate::model::TransitionKind),
182}
183
184const DRAG_SLOP: f32 = 6.0;
186
187pub(crate) fn drag_intent(ui: &egui::Ui) -> bool {
192 ui.input(|i| {
193 i.pointer.button_down(egui::PointerButton::Primary)
194 && match (i.pointer.press_origin(), i.pointer.interact_pos()) {
195 (Some(a), Some(b)) => a.distance(b) > DRAG_SLOP,
196 _ => false,
197 }
198 })
199}
200
201pub(crate) fn drag_source<P: std::any::Any + Send + Sync>(
206 ui: &mut egui::Ui,
207 id: egui::Id,
208 payload: P,
209 contents: impl FnOnce(&mut egui::Ui),
210) -> Response {
211 let dragging = ui.ctx().is_being_dragged(id) && drag_intent(ui);
212 let rect = if dragging {
213 let layer = egui::LayerId::new(egui::Order::Tooltip, id);
215 let r = ui.scope_builder(egui::UiBuilder::new().layer_id(layer), contents).response;
216 let (now, origin) = ui.ctx().input(|i| (i.pointer.interact_pos(), i.pointer.press_origin()));
219 if let (Some(p), Some(o)) = (now, origin) {
220 ui.ctx().transform_layer_shapes(layer, egui::emath::TSTransform::from_translation(p - o));
221 }
222 r.rect
223 } else {
224 ui.scope(contents).response.rect
225 };
226 let r = ui.interact(rect, id, egui::Sense::click_and_drag());
227 if dragging {
228 egui::DragAndDrop::set_payload(ui.ctx(), payload);
230 }
231 r
232}
233
234pub fn timecode(t: f64, fps: f64) -> String {
236 let t = t.max(0.0);
237 let fps = fps.max(1.0);
238 let total_frames = (t * fps).round() as u64;
239 let fpsr = fps.round() as u64;
240 let f = total_frames % fpsr;
241 let s = total_frames / fpsr;
242 format!("{:02}:{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60, f)
243}
244
245pub fn duration_text(t: f64) -> String {
247 let m = (t / 60.0).floor() as u64;
248 let s = t - m as f64 * 60.0;
249 format!("{m}:{s:04.1}")
250}
251
252pub fn label_color(idx: u8, palette: &Palette) -> egui::Color32 {
254 if idx == 0 || idx as usize > LABEL_COLORS.len() {
255 palette.text_dim
256 } else {
257 let [r, g, b] = LABEL_COLORS[idx as usize - 1].1;
258 egui::Color32::from_rgb(r, g, b)
259 }
260}
261
262pub fn label_name(idx: u8) -> &'static str {
264 if idx == 0 || idx as usize > LABEL_COLORS.len() {
265 "None"
266 } else {
267 LABEL_COLORS[idx as usize - 1].0
268 }
269}
270
271pub fn combo(ui: &mut egui::Ui, id: &str, value: &mut String, options: &[(&str, &str)], width: Option<f32>) -> bool {
273 let mut changed = false;
274 let current = options.iter().find(|(v, _)| *v == value.as_str()).map(|(_, l)| *l).unwrap_or(value.as_str());
275 let mut cb = egui::ComboBox::from_id_salt(id).selected_text(current);
276 if let Some(w) = width {
277 cb = cb.width(w);
278 }
279 cb.show_ui(ui, |ui| {
280 for (v, label) in options {
281 if ui.selectable_label(value.as_str() == *v, *label).clicked() && value.as_str() != *v {
282 *value = (*v).to_string();
283 changed = true;
284 }
285 }
286 });
287 changed
288}
289
290pub const ENCODER_PRESETS: [&str; 7] = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
292
293pub fn encoder_options(encoders: &[String]) -> Vec<&str> {
295 const KEYS: [&str; 9] = ["264", "265", "hevc", "vp9", "av1", "x264", "x265", "nvenc", "qsv"];
296 encoders.iter().map(String::as_str).filter(|e| KEYS.iter().any(|k| e.contains(k)) || e.contains("amf")).collect()
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn polygon_masks_never_stay_empty() {
305 let mut m = Mask::default();
306 seed_mask_points(&mut m);
307 assert!(m.points.is_empty(), "a rect mask is drawn from cx/cy/rx/ry, not points");
308 m.shape = MaskShape::Polygon;
309 seed_mask_points(&mut m);
310 assert_eq!(m.points.len(), 4, "{:?}", m.points);
312 assert!(m.points.iter().any(|p| p.0 < 0.0) && m.points.iter().any(|p| p.0 > 0.0));
313 m.points.push((5.0, 5.0));
314 let kept = m.points.clone();
315 seed_mask_points(&mut m);
316 assert_eq!(m.points, kept, "an existing outline is never overwritten");
317 }
318}