From 47e65461e85aa7f4c77ddd14c6806c7699ff8871 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 02:28:29 +0100 Subject: [PATCH] feat: animate overlay visibility changes --- CHANGELOG.md | 2 + Sources/Nameplate/OverlayController.swift | 61 +++- Sources/Nameplate/OverlayView.swift | 11 + linux/nameplate-core/src/lib.rs | 2 +- linux/nameplate/src/config.rs | 1 + linux/nameplate/src/overlays.rs | 281 ++++++++++++++++-- windows/Nameplate.App/NameplateApplication.cs | 2 +- windows/Nameplate.App/OverlayManager.cs | 237 +++++++++++++-- windows/Nameplate.App/OverlayWindows.cs | 5 + 9 files changed, 533 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99deab5..e6bb70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.3.1 - Unreleased +- Overlay visibility changes now fade smoothly on macOS, Windows, and Linux, with reduced-motion settings respected. + ## 0.3.0 - 2026-07-15 - macOS: attention alerts now dismiss on any mouse click without consuming it, so the intended control underneath still receives the click. diff --git a/Sources/Nameplate/OverlayController.swift b/Sources/Nameplate/OverlayController.swift index a29b753..6ae31e1 100644 --- a/Sources/Nameplate/OverlayController.swift +++ b/Sources/Nameplate/OverlayController.swift @@ -42,10 +42,13 @@ enum OverlayPanelFactory { /// controller only manages panel lifecycle and per-screen visibility. @MainActor final class OverlayController { + private static let visibilityAnimationDuration: TimeInterval = 0.2 + private let settings: AppSettings private let remoteMonitor: RemoteViewMonitor private let infoLineProvider: InfoLineProvider private var panels: [(panel: NSPanel, screen: NSScreen)] = [] + private var targetVisibility: [ObjectIdentifier: Bool] = [:] private var cancellable: AnyCancellable? // App-lifetime object: observers are registered once and never removed. @@ -61,7 +64,7 @@ final class OverlayController { .receive(on: DispatchQueue.main) .sink { [weak self] _ in DispatchQueue.main.async { - self?.applyVisibility() + self?.applyVisibility(animated: true) } } @@ -101,7 +104,7 @@ final class OverlayController { } self.panels[index].screen = screen } - self.applyVisibility() + self.applyVisibility(animated: false) } private var anyLayerEnabled: Bool { @@ -122,6 +125,7 @@ final class OverlayController { for (panel, _) in self.panels { panel.close() } + self.targetVisibility.removeAll() self.panels = NSScreen.screens.map { screen in // .statusBar floats above app windows and fullscreen content but // stays below pop-up menus. Anything higher (.screenSaver) blocks @@ -133,17 +137,56 @@ final class OverlayController { panel.setFrame(screen.frame, display: true) return (panel, screen) } - self.applyVisibility() + self.applyVisibility(animated: false) } - func applyVisibility() { + func applyVisibility(animated: Bool = true) { for (panel, screen) in self.panels { - if self.shouldShow(on: screen) { - if !panel.isVisible { - panel.orderFrontRegardless() - } - } else { + self.setVisible(self.shouldShow(on: screen), panel: panel, animated: animated) + } + } + + private func setVisible(_ visible: Bool, panel: NSPanel, animated: Bool) { + let identifier = ObjectIdentifier(panel) + guard self.targetVisibility[identifier] != visible else { return } + self.targetVisibility[identifier] = visible + + let shouldAnimate = animated && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + if visible { + if !panel.isVisible { + panel.alphaValue = shouldAnimate ? 0 : 1 + panel.orderFrontRegardless() + } + guard shouldAnimate else { + panel.alphaValue = 1 + return + } + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.visibilityAnimationDuration + panel.animator().alphaValue = 1 + } + return + } + + guard panel.isVisible else { + panel.alphaValue = 1 + return + } + guard shouldAnimate else { + panel.orderOut(nil) + panel.alphaValue = 1 + return + } + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.visibilityAnimationDuration + panel.animator().alphaValue = 0 + } completionHandler: { [weak self, weak panel] in + Task { @MainActor in + guard let self, let panel, + self.targetVisibility[ObjectIdentifier(panel)] == false + else { return } panel.orderOut(nil) + panel.alphaValue = 1 } } } diff --git a/Sources/Nameplate/OverlayView.swift b/Sources/Nameplate/OverlayView.swift index c0c6e27..e9bc255 100644 --- a/Sources/Nameplate/OverlayView.swift +++ b/Sources/Nameplate/OverlayView.swift @@ -31,6 +31,7 @@ extension AppSettings { struct OverlayView: View { @ObservedObject var settings: AppSettings @ObservedObject var infoLineProvider: InfoLineProvider + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let identity = self.settings.identity @@ -41,6 +42,7 @@ struct OverlayView: View { identity.color.opacity(self.settings.frameOpacity), lineWidth: self.settings.frameThickness) .ignoresSafeArea() + .transition(.opacity) } if self.settings.watermarkEnabled { @@ -50,6 +52,7 @@ struct OverlayView: View { maxHeight: .infinity, alignment: self.settings.watermarkCorner.alignment) .padding(self.layerPadding) + .transition(.opacity) } if self.settings.tagEnabled { @@ -62,12 +65,20 @@ struct OverlayView: View { maxHeight: .infinity, alignment: self.settings.tagCorner.alignment) .padding(self.layerPadding) + .transition(.opacity) } } + .animation(self.layerAnimation, value: self.settings.frameEnabled) + .animation(self.layerAnimation, value: self.settings.tagEnabled) + .animation(self.layerAnimation, value: self.settings.watermarkEnabled) .frame(maxWidth: .infinity, maxHeight: .infinity) .allowsHitTesting(false) } + private var layerAnimation: Animation? { + self.reduceMotion ? nil : .easeInOut(duration: 0.2) + } + private var layerPadding: CGFloat { (self.settings.frameEnabled ? self.settings.frameThickness : 0) + 10 } diff --git a/linux/nameplate-core/src/lib.rs b/linux/nameplate-core/src/lib.rs index 31ff633..bfd787f 100644 --- a/linux/nameplate-core/src/lib.rs +++ b/linux/nameplate-core/src/lib.rs @@ -129,7 +129,7 @@ pub enum Corner { BottomRight, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(default, rename_all = "camelCase")] pub struct Settings { pub name: Option, diff --git a/linux/nameplate/src/config.rs b/linux/nameplate/src/config.rs index 5ca774b..0c9f079 100644 --- a/linux/nameplate/src/config.rs +++ b/linux/nameplate/src/config.rs @@ -6,6 +6,7 @@ use std::fs; use std::path::PathBuf; use std::sync::mpsc; +#[derive(Clone, Debug, PartialEq)] pub struct LoadedConfig { pub settings: Settings, pub identity: Identity, diff --git a/linux/nameplate/src/overlays.rs b/linux/nameplate/src/overlays.rs index a5be450..d6ba6d9 100644 --- a/linux/nameplate/src/overlays.rs +++ b/linux/nameplate/src/overlays.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; pub struct OverlayManager { application: gtk::Application, config: config::LoadedConfig, - decoration_windows: Vec, + decoration_windows: Vec, transient_windows: Vec, attention_active: Rc>, attention_generation: Rc>, @@ -35,6 +35,39 @@ struct SplashVisualState { glow_scale: f64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DecorationLayer { + Frame, + Tag, + Watermark, +} + +impl DecorationLayer { + const ORDERED: [Self; 3] = [Self::Frame, Self::Tag, Self::Watermark]; + + fn draw_layer(self) -> DrawLayer { + match self { + Self::Frame => DrawLayer::Frame, + Self::Tag => DrawLayer::Tag, + Self::Watermark => DrawLayer::Watermark, + } + } + + fn is_enabled(self, settings: &Settings) -> bool { + match self { + Self::Frame => settings.frame_enabled, + Self::Tag => settings.tag_enabled, + Self::Watermark => settings.watermark_enabled, + } + } +} + +struct DecorationWindow { + layer: DecorationLayer, + window: gtk::Window, + animation_generation: Rc>, +} + impl OverlayManager { pub fn new(application: >k::Application) -> Self { Self { @@ -50,42 +83,69 @@ impl OverlayManager { } pub fn reload(&mut self) { - self.config = config::load(); - self.rebuild(); + let next = config::load(); + if next == self.config { + return; + } + let appearance_unchanged = self.config.identity == next.identity + && decoration_appearance(&self.config.settings) + == decoration_appearance(&next.settings); + self.config = next; + if appearance_unchanged { + self.reconcile_enabled_layers(true); + } else { + self.replace_decorations(true); + } } pub fn rebuild(&mut self) { - close_all(&mut self.decoration_windows); - let monitors = monitors(); - for monitor in monitors { - if self.config.settings.frame_enabled { - self.decoration_windows.push(make_window( - &self.application, - &monitor, - DrawLayer::Frame, - &self.config.identity, - &self.config.settings, - )); - } - if self.config.settings.tag_enabled { - self.decoration_windows.push(make_window( - &self.application, - &monitor, - DrawLayer::Tag, - &self.config.identity, - &self.config.settings, - )); - } - if self.config.settings.watermark_enabled { - self.decoration_windows.push(make_window( - &self.application, - &monitor, - DrawLayer::Watermark, - &self.config.identity, - &self.config.settings, - )); + self.replace_decorations(false); + } + + fn replace_decorations(&mut self, animate: bool) { + let previous = std::mem::take(&mut self.decoration_windows); + for monitor in monitors() { + for (layer, visible) in decoration_targets(&self.config.settings) { + self.add_decoration(&monitor, layer, visible, animate); } } + for decoration in previous { + retire_decoration(decoration, animate); + } + } + + fn reconcile_enabled_layers(&mut self, animate: bool) { + for decoration in &self.decoration_windows { + let visible = decoration.layer.is_enabled(&self.config.settings); + set_decoration_visibility(decoration, visible, animate); + } + } + + fn add_decoration( + &mut self, + monitor: >k::gdk::Monitor, + layer: DecorationLayer, + visible: bool, + animate: bool, + ) { + let should_animate = visible && animate && animations_enabled(); + let window = make_window( + &self.application, + monitor, + layer.draw_layer(), + &self.config.identity, + &self.config.settings, + if visible && !should_animate { 1.0 } else { 0.0 }, + ); + let decoration = DecorationWindow { + layer, + window, + animation_generation: Rc::new(Cell::new(0)), + }; + if should_animate { + animate_decoration(&decoration, 1.0, false); + } + self.decoration_windows.push(decoration); } pub fn show_splash(&mut self, force: bool) { @@ -125,6 +185,7 @@ impl OverlayManager { }, &self.config.identity, &self.config.settings, + 1.0, ); windows.push(window); areas.push(area); @@ -190,6 +251,7 @@ impl OverlayManager { }, &self.config.identity, &self.config.settings, + 1.0, ); windows.push(window); pulse_areas.push(area); @@ -206,6 +268,7 @@ impl OverlayManager { }, &self.config.identity, &self.config.settings, + 1.0, ); windows.push(window); } @@ -278,6 +341,99 @@ enum DrawLayer { }, } +#[derive(Clone, Copy, Debug, PartialEq)] +struct DecorationAppearance { + frame_thickness: f64, + frame_opacity: f64, + frame_corner_radius: f64, + frame_round_top_left: bool, + frame_round_top_right: bool, + frame_round_bottom_left: bool, + frame_round_bottom_right: bool, + tag_corner: Corner, + tag_shows_glyph: bool, + watermark_corner: Corner, + watermark_opacity: f64, +} + +fn decoration_appearance(settings: &Settings) -> DecorationAppearance { + DecorationAppearance { + frame_thickness: settings.frame_thickness, + frame_opacity: settings.frame_opacity, + frame_corner_radius: settings.frame_corner_radius, + frame_round_top_left: settings.frame_round_top_left, + frame_round_top_right: settings.frame_round_top_right, + frame_round_bottom_left: settings.frame_round_bottom_left, + frame_round_bottom_right: settings.frame_round_bottom_right, + tag_corner: settings.tag_corner, + tag_shows_glyph: settings.tag_shows_glyph, + watermark_corner: settings.watermark_corner, + watermark_opacity: settings.watermark_opacity, + } +} + +fn decoration_targets(settings: &Settings) -> [(DecorationLayer, bool); 3] { + DecorationLayer::ORDERED.map(|layer| (layer, layer.is_enabled(settings))) +} + +fn animations_enabled() -> bool { + gtk::Settings::default().is_none_or(|settings| settings.is_gtk_enable_animations()) +} + +fn set_decoration_visibility(decoration: &DecorationWindow, visible: bool, animate: bool) { + let target_opacity = if visible { 1.0 } else { 0.0 }; + decoration + .animation_generation + .set(decoration.animation_generation.get().wrapping_add(1)); + if (decoration.window.opacity() - target_opacity).abs() < f64::EPSILON { + return; + } + if animate && animations_enabled() { + animate_decoration(decoration, target_opacity, false); + } else { + decoration.window.set_opacity(target_opacity); + } +} + +fn retire_decoration(decoration: DecorationWindow, animate: bool) { + if animate && animations_enabled() { + animate_decoration(&decoration, 0.0, true); + } else { + decoration.window.close(); + } +} + +fn animate_decoration(decoration: &DecorationWindow, target_opacity: f64, close: bool) { + const DURATION: f64 = 0.2; + + let generation = decoration.animation_generation.get().wrapping_add(1); + decoration.animation_generation.set(generation); + let animation_generation = Rc::clone(&decoration.animation_generation); + let window = decoration.window.clone(); + let initial_opacity = window.opacity(); + let started = Instant::now(); + gtk::glib::timeout_add_local(Duration::from_millis(16), move || { + if animation_generation.get() != generation { + return gtk::glib::ControlFlow::Break; + } + let animation_progress = (started.elapsed().as_secs_f64() / DURATION).clamp(0.0, 1.0); + let opacity = lerp( + initial_opacity, + target_opacity, + smoothstep(animation_progress), + ); + window.set_opacity(opacity); + if animation_progress >= 1.0 { + if close { + window.close(); + } + gtk::glib::ControlFlow::Break + } else { + gtk::glib::ControlFlow::Continue + } + }); +} + fn monitors() -> Vec { let Some(display) = gtk::gdk::Display::default() else { return Vec::new(); @@ -295,8 +451,17 @@ fn make_window( layer: DrawLayer, identity: &Identity, settings: &Settings, + initial_opacity: f64, ) -> gtk::Window { - make_window_with_area(application, monitor, layer, identity, settings).0 + make_window_with_area( + application, + monitor, + layer, + identity, + settings, + initial_opacity, + ) + .0 } fn make_window_with_area( @@ -305,6 +470,7 @@ fn make_window_with_area( layer: DrawLayer, identity: &Identity, settings: &Settings, + initial_opacity: f64, ) -> (gtk::Window, gtk::DrawingArea) { let geometry = monitor.geometry(); let window = gtk::Window::builder() @@ -315,6 +481,7 @@ fn make_window_with_area( .default_width(geometry.width()) .default_height(geometry.height()) .build(); + window.set_opacity(initial_opacity); window.add_css_class("nameplate-overlay"); window.set_can_focus(false); let area = gtk::DrawingArea::new(); @@ -864,6 +1031,54 @@ fn fade_out(windows: Vec) { mod tests { use super::*; + #[test] + fn decoration_appearance_ignores_non_decoration_settings() { + let original = Settings::default(); + let toggled = Settings { + name: Some("renamed".to_owned()), + color: Some("#123456".to_owned()), + glyph: Some("test".to_owned()), + use_fleet_file: !original.use_fleet_file, + frame_enabled: !original.frame_enabled, + tag_enabled: !original.tag_enabled, + watermark_enabled: !original.watermark_enabled, + splash_enabled: !original.splash_enabled, + splash_duration: original.splash_duration + 1.0, + ..original.clone() + }; + assert_eq!( + decoration_appearance(&original), + decoration_appearance(&toggled) + ); + + let restyled = Settings { + frame_opacity: 0.25, + ..original.clone() + }; + assert_ne!( + decoration_appearance(&original), + decoration_appearance(&restyled) + ); + } + + #[test] + fn decoration_targets_keep_disabled_layers_in_canonical_order() { + let settings = Settings { + frame_enabled: true, + tag_enabled: false, + watermark_enabled: true, + ..Settings::default() + }; + assert_eq!( + decoration_targets(&settings), + [ + (DecorationLayer::Frame, true), + (DecorationLayer::Tag, false), + (DecorationLayer::Watermark, true), + ] + ); + } + #[test] fn animated_splash_traces_then_exits() { let start = splash_visual_state(SplashAnimation { diff --git a/windows/Nameplate.App/NameplateApplication.cs b/windows/Nameplate.App/NameplateApplication.cs index 251ef91..7ae0ec0 100644 --- a/windows/Nameplate.App/NameplateApplication.cs +++ b/windows/Nameplate.App/NameplateApplication.cs @@ -96,6 +96,6 @@ private void OnConfigChanged(object? sender, EventArgs args) private void OnDisplaySettingsChanged(object? sender, EventArgs args) { - _ = Dispatcher.InvokeAsync(() => overlays?.Rebuild()); + _ = Dispatcher.InvokeAsync(() => overlays?.Rebuild(animate: false, force: true)); } } diff --git a/windows/Nameplate.App/OverlayManager.cs b/windows/Nameplate.App/OverlayManager.cs index 4a998c8..e047743 100644 --- a/windows/Nameplate.App/OverlayManager.cs +++ b/windows/Nameplate.App/OverlayManager.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Windows; using System.Windows.Media; +using System.Windows.Media.Animation; using Nameplate.Core; using Forms = System.Windows.Forms; @@ -9,52 +10,74 @@ namespace Nameplate.App; internal sealed class OverlayManager : IDisposable { - private readonly List persistentWindows = []; + private static readonly TimeSpan DecorationAnimationDuration = TimeSpan.FromMilliseconds(200); + + private readonly List persistentWindows = []; + private readonly HashSet retiringWindows = []; private readonly List splashWindows = []; private readonly List attentionWindows = []; private readonly ConfigStore config; + private MachineIdentity? renderedIdentity; + private LayerSettings? renderedSettings; + private string[] renderedScreens = []; private GlobalMouseMonitor? attentionClickMonitor; private int attentionGeneration; + private enum DecorationLayer + { + Frame, + Tag, + Watermark, + } + + private sealed record DecorationWindow(DecorationLayer Layer, string ScreenKey, OverlayWindow Window); + public OverlayManager(ConfigStore config) { this.config = config; - Rebuild(); + Rebuild(animate: false, force: true); } public void Dispose() { - CloseWindows(persistentWindows); + ClosePersistentImmediately(); CloseWindows(splashWindows); CloseAttention(); } - public void Rebuild() + public void Rebuild(bool animate = true, bool force = false) { - CloseWindows(persistentWindows); - CloseWindows(splashWindows); - CloseAttention(); var identity = config.Identity; var settings = config.Settings.Layers; - var accent = Brush(identity.ColorHex); - - foreach (var screen in Forms.Screen.AllScreens) + var screens = Forms.Screen.AllScreens; + var screenKeys = screens.Select(ScreenKey).ToArray(); + var appearanceChanged = renderedIdentity != identity + || renderedSettings is null + || AppearanceOnly(renderedSettings) != AppearanceOnly(settings); + var screensChanged = !renderedScreens.SequenceEqual(screenKeys); + var enabledLayersChanged = renderedSettings is null + || renderedSettings.FrameEnabled != settings.FrameEnabled + || renderedSettings.TagEnabled != settings.TagEnabled + || renderedSettings.WatermarkEnabled != settings.WatermarkEnabled; + if (!force && !appearanceChanged && !screensChanged && !enabledLayersChanged) { - if (settings.FrameEnabled) - { - ShowPersistent(new FrameWindow(screen, accent, settings)); - } - - if (settings.TagEnabled) - { - ShowPersistent(new TagWindow(screen, accent, identity, settings.TagCorner)); - } + return; + } - if (settings.WatermarkEnabled) - { - ShowPersistent(new WatermarkWindow(screen, accent, identity, settings.WatermarkCorner, settings.WatermarkOpacity)); - } + CloseWindows(splashWindows); + CloseAttention(); + if (force || appearanceChanged || screensChanged) + { + ReplaceDecorations(screens, identity, settings, animate); } + else + { + ReconcileEnabledLayers(screens, identity, settings, animate); + } + + renderedIdentity = identity; + renderedSettings = settings; + renderedScreens = screenKeys; } public void ShowSplash() @@ -137,10 +160,174 @@ private void CloseAttention() CloseWindows(attentionWindows); } - private void ShowPersistent(Window window) + private void ReplaceDecorations( + Forms.Screen[] screens, + MachineIdentity identity, + LayerSettings settings, + bool animate) + { + var previous = persistentWindows.ToArray(); + persistentWindows.Clear(); + foreach (var screen in screens) + { + foreach (var layer in EnabledLayers(settings)) + { + AddDecoration(layer, screen, identity, settings, animate); + } + } + + foreach (var decoration in previous) + { + HidePersistent(decoration.Window, animate); + } + } + + private void ReconcileEnabledLayers( + Forms.Screen[] screens, + MachineIdentity identity, + LayerSettings settings, + bool animate) + { + var enabled = EnabledLayers(settings).ToHashSet(); + foreach (var decoration in persistentWindows.Where(item => !enabled.Contains(item.Layer)).ToArray()) + { + persistentWindows.Remove(decoration); + HidePersistent(decoration.Window, animate); + } + + foreach (var screen in screens) + { + var screenKey = ScreenKey(screen); + foreach (var layer in enabled) + { + if (persistentWindows.Any(item => item.Layer == layer && item.ScreenKey == screenKey)) + { + continue; + } + AddDecoration(layer, screen, identity, settings, animate); + } + } + RestackDecorations(screens, settings); + } + + private void RestackDecorations(Forms.Screen[] screens, LayerSettings settings) + { + foreach (var screen in screens) + { + var screenKey = ScreenKey(screen); + foreach (var layer in EnabledLayers(settings)) + { + persistentWindows + .First(item => item.Layer == layer && item.ScreenKey == screenKey) + .Window + .BringToFront(); + } + } + } + + private void AddDecoration( + DecorationLayer layer, + Forms.Screen screen, + MachineIdentity identity, + LayerSettings settings, + bool animate) + { + var accent = Brush(identity.ColorHex); + OverlayWindow window = layer switch + { + DecorationLayer.Frame => new FrameWindow(screen, accent, settings), + DecorationLayer.Tag => new TagWindow(screen, accent, identity, settings.TagCorner), + DecorationLayer.Watermark => new WatermarkWindow( + screen, + accent, + identity, + settings.WatermarkCorner, + settings.WatermarkOpacity), + _ => throw new ArgumentOutOfRangeException(nameof(layer)), + }; + persistentWindows.Add(new DecorationWindow(layer, ScreenKey(screen), window)); + ShowPersistent(window, animate); + } + + private static IEnumerable EnabledLayers(LayerSettings settings) + { + if (settings.FrameEnabled) yield return DecorationLayer.Frame; + if (settings.TagEnabled) yield return DecorationLayer.Tag; + if (settings.WatermarkEnabled) yield return DecorationLayer.Watermark; + } + + private static LayerSettings AppearanceOnly(LayerSettings settings) => settings with { - persistentWindows.Add(window); + FrameEnabled = false, + TagEnabled = false, + WatermarkEnabled = false, + }; + + private static string ScreenKey(Forms.Screen screen) => + $"{screen.DeviceName}:{screen.Bounds.X}:{screen.Bounds.Y}:{screen.Bounds.Width}:{screen.Bounds.Height}"; + + private static void ShowPersistent(Window window, bool animate) + { + var shouldAnimate = animate && SystemParameters.ClientAreaAnimation; + window.Opacity = shouldAnimate ? 0 : 1; window.Show(); + if (!shouldAnimate) + { + return; + } + + var animation = new DoubleAnimation(0, 1, DecorationAnimationDuration) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut }, + }; + animation.Completed += (_, _) => + { + if (!window.IsLoaded) return; + window.BeginAnimation(Window.OpacityProperty, null); + window.Opacity = 1; + }; + window.BeginAnimation(Window.OpacityProperty, animation); + } + + private void HidePersistent(Window window, bool animate) + { + if (!animate || !SystemParameters.ClientAreaAnimation || !window.IsLoaded) + { + window.Close(); + return; + } + + var currentOpacity = window.Opacity; + window.BeginAnimation(Window.OpacityProperty, null); + window.Opacity = currentOpacity; + retiringWindows.Add(window); + var animation = new DoubleAnimation(currentOpacity, 0, DecorationAnimationDuration) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut }, + }; + animation.Completed += (_, _) => + { + retiringWindows.Remove(window); + if (window.IsLoaded) + { + window.Close(); + } + }; + window.BeginAnimation(Window.OpacityProperty, animation); + } + + private void ClosePersistentImmediately() + { + foreach (var decoration in persistentWindows.ToArray()) + { + decoration.Window.Close(); + } + persistentWindows.Clear(); + foreach (var window in retiringWindows.ToArray()) + { + window.Close(); + } + retiringWindows.Clear(); } private static void CloseWindows(List windows) where T : Window diff --git a/windows/Nameplate.App/OverlayWindows.cs b/windows/Nameplate.App/OverlayWindows.cs index 8aef79f..7ad549e 100644 --- a/windows/Nameplate.App/OverlayWindows.cs +++ b/windows/Nameplate.App/OverlayWindows.cs @@ -37,6 +37,11 @@ protected OverlayWindow(Forms.Screen screen, bool clickThrough) protected Forms.Screen Screen { get; } + internal void BringToFront() + { + NativeMethods.PositionWindow(new WindowInteropHelper(this).Handle, Screen.Bounds); + } + private bool ClickThrough { get; } private void OnSourceInitialized(object? sender, EventArgs args)