Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 52 additions & 9 deletions Sources/Nameplate/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -61,7 +64,7 @@ final class OverlayController {
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
DispatchQueue.main.async {
self?.applyVisibility()
self?.applyVisibility(animated: true)
}
}

Expand Down Expand Up @@ -101,7 +104,7 @@ final class OverlayController {
}
self.panels[index].screen = screen
}
self.applyVisibility()
self.applyVisibility(animated: false)
}

private var anyLayerEnabled: Bool {
Expand All @@ -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
Expand All @@ -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
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Nameplate/OverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,6 +42,7 @@ struct OverlayView: View {
identity.color.opacity(self.settings.frameOpacity),
lineWidth: self.settings.frameThickness)
.ignoresSafeArea()
.transition(.opacity)
}

if self.settings.watermarkEnabled {
Expand All @@ -50,6 +52,7 @@ struct OverlayView: View {
maxHeight: .infinity,
alignment: self.settings.watermarkCorner.alignment)
.padding(self.layerPadding)
.transition(.opacity)
}

if self.settings.tagEnabled {
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion linux/nameplate-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand Down
1 change: 1 addition & 0 deletions linux/nameplate/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading