simple_editor\ui/
mod.rs

1//! egui UI. Dockable layout (ui/layout.rs) of panes: Library, Preview, Inspector, Effects, Transitions,
2//! Subtitles, Timeline, Curves, Planner, Auto-cut, Tracking — DaVinci-like by default, bare Windows-forms styling.
3//! Windows (Settings, Retime, Export) never block the editor.
4
5pub 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
36/// True on the first frame of an edit gesture (drag start, or a non-drag change such as typing/clicking).
37pub(crate) fn edit_start(r: &Response) -> bool {
38    r.drag_started() || (r.changed() && !r.dragged())
39}
40
41/// Accumulates widget responses over one frame: `start` → push undo once, `changed` → write back.
42/// Shared by every panel so the gesture rule lives in one place.
43#[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    /// Text fields: the gesture starts when the field is entered, so typing a paragraph is one undo
55    /// entry instead of one per character (which used to evict the whole undo stack).
56    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
66/// Push undo at most once per frame.
67pub(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
74/// Diamond keyframe toggle at the clip-local playhead + a "keys" clear button once it is animated.
75pub(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
99/// A Polygon/Path mask is drawn from `points`; with fewer than 3 vertices the rasteriser reports
100/// "outside everywhere" and the masked clip disappears with no way back. Seed the vertices from the
101/// mask's own rect so every shape switch leaves something visible and editable.
102pub(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
111/// Mask parameter grid, shared by the Inspector (the clip mask) and the Effects panel (a per-effect
112/// mask): shape, enabled, invert, position/size/rotation, feather, expand, opacity — each keyframable.
113pub(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        // X/Y/radius are here too: a mask added from a panel has no viewport drag behind it and would
130        // otherwise be stuck at its default rect in the middle of the layer.
131        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/// Drag-and-drop payload from the library / recent / planner panels to the timeline (egui dnd API).
168#[derive(Clone, Debug)]
169pub enum DragPayload {
170    /// A library asset id.
171    Asset(Id),
172    /// A file path (recent panel, linked folders) — the timeline reports it back as a dropped file.
173    Path(String),
174    /// A nested timeline (Project.sequences) id.
175    Sequence(Id),
176    /// A saved template (Settings.templates) by name.
177    Template(String),
178    /// An effect from the effects panel (dropped on a clip or the node canvas).
179    Effect(crate::model::EffectKind),
180    /// A transition from the transitions panel (dropped on a cut or the node canvas).
181    Transition(crate::model::TransitionKind),
182}
183
184/// How far the pointer must travel while held before a click turns into a drag.
185const DRAG_SLOP: f32 = 6.0;
186
187/// "The user really means to drag this": the primary button is down and the pointer has moved past
188/// `DRAG_SLOP`. egui also promotes a *stationary* long press to a drag (`is_decidedly_dragging`), and
189/// its `Sense::drag` widgets start dragging on the press of ANY button — which is how a right-click
190/// used to pluck a card out of a panel instead of opening its menu.
191pub(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
201/// An item that is clickable and right-clickable first and a drag-and-drop source second: the payload
202/// is only handed to egui once `drag_intent` holds, and the body is lifted under the cursor from that
203/// moment (the one thing `Ui::dnd_drag_source` is good for). Use this instead of `dnd_drag_source`,
204/// which senses drag only — grab cursor on hover, and a drag on any press.
205pub(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        // paint the body into its own tooltip-order layer, then move that layer under the cursor
214        let layer = egui::LayerId::new(egui::Order::Tooltip, id);
215        let r = ui.scope_builder(egui::UiBuilder::new().layer_id(layer), contents).response;
216        // translate by how far the pointer has travelled, NOT by (pointer - centre): snapping the body's
217        // centre under the cursor makes anything grabbed off-centre jump the moment the drag starts.
218        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        // re-set every frame of the drag: egui drops the payload on release, never mid-gesture
229        egui::DragAndDrop::set_payload(ui.ctx(), payload);
230    }
231    r
232}
233
234/// HH:MM:SS:FF timecode.
235pub 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
245/// Short duration text "1:23.4".
246pub 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
252/// Colour of a clip/asset/recent label index (0 or out of range = "no label" dim).
253pub 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
262/// Name of a label index ("None" for 0 / out of range).
263pub 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
271/// ComboBox over (value, label) pairs writing into a String setting. Returns true if the value changed.
272pub 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
290/// x264-style speed presets offered by the settings and export windows.
291pub const ENCODER_PRESETS: [&str; 7] = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
292
293/// Encoder names worth offering: the h264 / hevc / vp9 / av1 families (software + nvenc/qsv/amf).
294pub 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        // < 3 points rasterises as "outside everywhere": the masked clip would vanish
311        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}