diff --git a/.github/release-notes.md b/.github/release-notes.md index 228cada..0aec052 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -3,24 +3,16 @@ matching, timecode, and recording metadata. ## What's new in @VERSION@ -- **Much faster synchronization.** MOV, MP4 and WAV (including RF64/BW64) are - read natively instead of through FFmpeg. On a 22-file test set, a full - synchronization dropped from about 11 s to under 1 s with the same accuracy. -- **Faster media export.** Camera files with replacement audio are written - natively, in a fraction of a second instead of several seconds. -- **Smaller macOS app.** macOS no longer bundles FFmpeg; Linux and Windows ship - a slimmer FFmpeg build (about 9 MB instead of 19 MB). -- **Refined interface.** Round timeline ruler steps, smoother progress, - a real drop zone, clearer search-accuracy settings, and Ctrl shortcuts on - Windows and Linux (F11 for full screen). -- **Updates on every platform.** Align checks for a new release at launch and - offers it in a small card. One click downloads it, verifies its checksum and - installs it: through the Windows installer, by replacing the app on macOS, or - through `dnf`/`pacman` on Fedora and Arch. Copies of 0.1.0 do not check for - updates, so install this release manually once. -- **Windows fixes.** Premiere Pro and Final Cut Pro exports now reference - Windows media with valid file URLs, the app has its icon, and no console - windows flash while it works. +- **Application menu on Windows and Linux.** The Menu button is available in + the top toolbar and on the empty start screen. It opens About, Check for + Updates, Use with AI Agents, Path Fixer, analysis-cache settings, and theme + selection using the same actions as the native macOS menu. macOS continues + to use its native menu bar without an in-window Menu button. +- **Consistent menu contents.** The in-window menu follows the registered + application menus, including the selected theme. macOS system items such as + Services, Hide, and Window are omitted. +- **Escape to dismiss.** Escape closes the application menu, timeline context + menu, and About panel. Clicking outside the application menu also closes it. ## Downloads @@ -49,8 +41,8 @@ open "/Applications/Align.app" Run `Align-@VERSION@-Windows-x64-Setup.exe`. It installs for the current user, adds Align to the Start menu, and installs the Microsoft VC++ runtime if needed. -Running it over 0.1.0 upgrades that installation. The installer is unsigned, so -SmartScreen may ask for confirmation. +Running it over an earlier version upgrades that installation. The installer is +unsigned, so SmartScreen may ask for confirmation. ### Fedora and Omarchy / Arch diff --git a/Cargo.lock b/Cargo.lock index a98b2d0..f70ee0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "align-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "align-core", "align-decode", @@ -56,7 +56,7 @@ dependencies = [ [[package]] name = "align-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bincode", "blake3", @@ -73,7 +73,7 @@ dependencies = [ [[package]] name = "align-decode" -version = "0.2.0" +version = "0.3.0" dependencies = [ "align-core", "blake3", @@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "align-gpui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "align-core", "align-decode", @@ -120,7 +120,7 @@ dependencies = [ [[package]] name = "align-mcp" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 2f74a26..b013dda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "2" edition = "2024" rust-version = "1.85" license = "GPL-3.0-only" -version = "0.2.0" +version = "0.3.0" [workspace.dependencies] align-core = { path = "crates/align-core" } diff --git a/README.md b/README.md index 83a45ec..7483e07 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ Synchronize camera footage and separately recorded audio using waveforms, timecode, and recording metadata. -Download for macOS  -Download for Windows +Download for macOS  +Download for Windows -Version 0.2.0 · [Linux (Fedora, Arch) and all downloads](https://github.com/FedyaLight/align/releases/latest) +Version 0.3.0 · [Linux (Fedora, Arch) and all downloads](https://github.com/FedyaLight/align/releases/latest) Align synchronizing a multicam shoot: cameras and recorders dropped in, clips turn green on one timeline diff --git a/Support/Linux/com.align.app.metainfo.xml b/Support/Linux/com.align.app.metainfo.xml index b39ec8b..8bcad0f 100644 --- a/Support/Linux/com.align.app.metainfo.xml +++ b/Support/Linux/com.align.app.metainfo.xml @@ -18,6 +18,7 @@ align + diff --git a/crates/align-gpui/src/app_menu.rs b/crates/align-gpui/src/app_menu.rs new file mode 100644 index 0000000..19fd3de --- /dev/null +++ b/crates/align-gpui/src/app_menu.rs @@ -0,0 +1,97 @@ +//! Project the registered application menus into the in-window menu. + +use gpui::{OwnedMenu, OwnedMenuItem}; + +use crate::{HideApp, HideOthersApp, ShowAllApps}; + +pub fn in_window_menus(menus: Vec) -> Vec { + menus + .into_iter() + .filter_map(|mut menu| { + if menu.name.as_ref() == "Window" { + return None; + } + let mut items = Vec::new(); + for item in menu.items { + let item = match item { + OwnedMenuItem::SystemMenu(_) => continue, + OwnedMenuItem::Action { ref action, .. } + if action.as_any().is::() + || action.as_any().is::() + || action.as_any().is::() => + { + continue; + } + OwnedMenuItem::Submenu(submenu) => { + let Some(submenu) = in_window_menus(vec![submenu]).pop() else { + continue; + }; + OwnedMenuItem::Submenu(submenu) + } + item => item, + }; + if matches!(item, OwnedMenuItem::Separator) + && (items.is_empty() || matches!(items.last(), Some(OwnedMenuItem::Separator))) + { + continue; + } + items.push(item); + } + if matches!(items.last(), Some(OwnedMenuItem::Separator)) { + items.pop(); + } + menu.items = items; + (!menu.items.is_empty()).then_some(menu) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::LightAppearance; + use gpui::{Menu, MenuItem, SystemMenuType}; + + #[test] + fn registered_submenus_keep_their_actions_without_platform_only_items() { + let menus = vec![ + Menu { + name: "Custom".into(), + items: vec![ + MenuItem::separator(), + MenuItem::action("A renamed system command", HideApp), + MenuItem::os_submenu("Services", SystemMenuType::Services), + MenuItem::submenu(Menu { + name: "New settings group".into(), + items: vec![ + MenuItem::action("Another system command", HideOthersApp), + MenuItem::separator(), + MenuItem::action("✓ Custom appearance", LightAppearance), + MenuItem::separator(), + MenuItem::action("Show applications", ShowAllApps), + ], + }), + MenuItem::separator(), + ], + } + .owned(), + Menu { + name: "Window".into(), + items: vec![MenuItem::action("Window command", LightAppearance)], + } + .owned(), + ]; + + let projected = in_window_menus(menus); + assert_eq!(projected.len(), 1, "Window must not appear in the popup"); + let [OwnedMenuItem::Submenu(submenu)] = projected[0].items.as_slice() else { + panic!("Only the custom settings group should remain"); + }; + assert_eq!(submenu.name.as_ref(), "New settings group"); + let [OwnedMenuItem::Action { name, action, .. }] = submenu.items.as_slice() else { + panic!("System commands and orphan separators must be removed recursively"); + }; + assert_eq!(name, "✓ Custom appearance"); + assert!(action.as_any().is::()); + } +} diff --git a/crates/align-gpui/src/main.rs b/crates/align-gpui/src/main.rs index ed96049..04338ba 100644 --- a/crates/align-gpui/src/main.rs +++ b/crates/align-gpui/src/main.rs @@ -5,6 +5,7 @@ #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; +mod app_menu; mod icons; mod lane; mod motion; diff --git a/crates/align-gpui/src/views.rs b/crates/align-gpui/src/views.rs index 2ce4577..98a6c18 100644 --- a/crates/align-gpui/src/views.rs +++ b/crates/align-gpui/src/views.rs @@ -14,9 +14,10 @@ use futures::StreamExt; use gpui::{ AnchoredPositionMode, Animation, AnimationExt, AnyView, App, BoxShadow, ClickEvent, ClipboardItem, Context, Corner, Div, DragMoveEvent, Entity, FocusHandle, Focusable, - IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, - PathPromptOptions, Pixels, Point, Render, ScrollHandle, SharedString, Stateful, Styled, Window, - anchored, deferred, div, img, point, prelude::*, px, rgb, rgba, + IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, OwnedMenu, + OwnedMenuItem, ParentElement, PathPromptOptions, Pixels, Point, Render, ScrollHandle, + SharedString, Stateful, Styled, Window, anchored, deferred, div, img, point, prelude::*, px, + rgb, rgba, }; use super::icons::{icons, kind_badge, svg_icon}; @@ -805,6 +806,7 @@ pub struct AlignApp { export_inputs: ExportInputs, path_fixer_inputs: PathFixerInputs, pan_origin: Option>, + app_menu_position: Option>, export_scroll: ScrollHandle, export_scroll_drag: Option<(f32, f32)>, export_sidebar_closing: bool, @@ -845,6 +847,7 @@ impl AlignApp { export_inputs: ExportInputs::new(cx), path_fixer_inputs: PathFixerInputs::new(cx), pan_origin: None, + app_menu_position: None, export_scroll: ScrollHandle::new(), export_scroll_drag: None, export_sidebar_closing: false, @@ -2459,7 +2462,8 @@ impl Render for AlignApp { // While a modal or context menu is open the content below keeps // its layout but loses hover/cursor feedback (clicks are already // swallowed by the overlay layers). - let content_active = self.data.menu.is_none() + let content_active = self.app_menu_position.is_none() + && self.data.menu.is_none() && !self.data.show_path_fixer && self.data.error.is_none() && self.data.sequence_picker.is_none() @@ -2498,6 +2502,22 @@ impl Render for AlignApp { // export text field owns keyboard focus. .on_key_down(cx.listener(|this, e: &KeyDownEvent, window, cx| { let key: &str = &e.keystroke.key; + if key == "escape" && this.app_menu_position.take().is_some() { + cx.notify(); + return; + } + if key == "escape" && this.data.menu.take().is_some() { + cx.notify(); + return; + } + if key == "escape" && this.data.show_about { + this.data.show_about = false; + cx.notify(); + return; + } + if this.app_menu_position.is_some() || this.data.show_about { + return; + } if key == "escape" && this.export_select.is_some() { this.close_export_select(cx); return; @@ -2614,6 +2634,38 @@ impl Render for AlignApp { content = content.child(sidebar_scrim(&theme, self.export_sidebar_closing)); } root = root.child(content); + if let Some(position) = self.app_menu_position { + root = root.child( + div() + .absolute() + .top(px(0.)) + .left(px(0.)) + .size_full() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.app_menu_position = None; + cx.notify(); + cx.stop_propagation(); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _, cx| { + this.app_menu_position = None; + cx.notify(); + cx.stop_propagation(); + }), + ), + ); + root = root.child(deferred( + anchored() + .anchor(Corner::TopLeft) + .position(position) + .snap_to_window_with_margin(px(8.)) + .child(app_menu_panel(cx, &theme, window)), + )); + } if self.data.show_export { root = root .child(export_sidebar( @@ -2733,6 +2785,103 @@ impl Render for AlignApp { // ---------------- top toolbar (sources / sync / export) +fn app_menu_button(cx: &mut Context, theme: &Theme, active: bool) -> impl IntoElement { + button( + cx, + theme, + "btn-app-menu", + div() + .flex() + .items_center() + .gap_1() + .child("Menu") + .child(svg_icon(icons().chevron_down.clone(), 12., theme.icon)), + active, + |this, event, window, cx| { + this.data.menu = None; + this.app_menu_position = Some(event.position() + point(px(0.), px(12.))); + this.focus_handle.focus(window); + cx.notify(); + cx.stop_propagation(); + }, + ) +} + +fn app_menu_panel(cx: &mut Context, theme: &Theme, window: &Window) -> Stateful
{ + let menus = super::app_menu::in_window_menus(cx.get_menus().unwrap_or_default()); + let mut panel = div() + .id("app-menu") + .flex() + .flex_col() + .p_1() + .min_w(px(260.)) + .max_h(px( + (f32::from(window.viewport_size().height) - 32.).clamp(100., 560.) + )) + .overflow_y_scroll() + .rounded_lg() + .border_1() + .border_color(rgb(theme.border)) + .bg(rgb(theme.panel)) + .shadow_md() + .occlude() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation()) + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()); + for menu in menus { + panel = append_app_menu(cx, theme, panel, menu, String::new()); + } + panel +} + +fn append_app_menu( + cx: &mut Context, + theme: &Theme, + mut panel: Stateful
, + menu: OwnedMenu, + parent: String, +) -> Stateful
{ + let path = if parent.is_empty() { + menu.name.to_string() + } else { + format!("{parent} › {}", menu.name) + }; + panel = panel.child(menu_header(theme, path.clone())); + for (index, item) in menu.items.into_iter().enumerate() { + match item { + OwnedMenuItem::Action { name, action, .. } => { + panel = panel.child(menu_row( + cx, + theme, + format!("app-menu-{path}-{index}"), + name, + false, + move |this, _, window, cx| { + this.app_menu_position = None; + cx.notify(); + window.dispatch_action(action.boxed_clone(), cx); + cx.stop_propagation(); + }, + )); + } + OwnedMenuItem::Submenu(submenu) => { + panel = append_app_menu(cx, theme, panel, submenu, path.clone()); + } + OwnedMenuItem::Separator => { + panel = panel.child( + div() + .flex_shrink_0() + .my_1() + .h(px(1.)) + .bg(rgb(theme.separator)), + ); + } + OwnedMenuItem::SystemMenu(_) => {} + } + } + panel +} + fn toolbar( cx: &mut Context, theme: &Theme, @@ -2758,6 +2907,9 @@ fn toolbar( .border_b_1() .border_color(rgb(theme.separator)) .bg(rgb(theme.panel)); + if cfg!(any(target_os = "windows", target_os = "linux")) { + bar = bar.child(app_menu_button(cx, theme, active)); + } bar = bar.child(icon_button( cx, theme, @@ -3014,11 +3166,24 @@ fn drop_zone( .bg(rgba((accent << 8) | 0x14)) }); div() + .relative() .flex_1() .flex() .items_center() .justify_center() .p_8() + .when( + cfg!(any(target_os = "windows", target_os = "linux")), + |zone| { + zone.child( + div() + .absolute() + .top(px(9.)) + .left(px(12.)) + .child(app_menu_button(cx, theme, active)), + ) + }, + ) .child( panel .child( @@ -7245,7 +7410,7 @@ fn update_action_label(update: &updater::Update) -> String { } /// Floating card announcing a new release, with the next step inline. It -/// works on every platform (the About panel is in the macOS menu only). +/// works on every platform, alongside the About panel's update controls. fn update_notice( cx: &mut Context, theme: &Theme, diff --git a/docs/usage.md b/docs/usage.md index cb93203..abe688d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -40,6 +40,11 @@ other tracks align to them. See [format limits](formats.md) for unsupported edit | Full screen | Ctrl+Cmd+F | F11 | | Quit | Cmd+Q | Ctrl+Q | +On Windows and Linux, the **Menu** button is available in the top toolbar and +on the empty start screen. It provides About, Check for Updates, Use with AI +Agents, Path Fixer, analysis-cache settings, and appearance selection. On macOS, +these commands are available in the native menu bar. + Escape closes the open menu, panel, or dialog. ## CLI basics