use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
use objc2_app_kit::{
NSBackingStoreType, NSBezierPath, NSColor, NSCompositingOperation, NSFloatingWindowLevel,
NSFont, NSGraphicsContext, NSParagraphStyle, NSScreen, NSView, NSWindow,
NSWindowCollectionBehavior, NSWindowStyleMask,
};
use objc2_core_foundation::CGFloat;
use objc2_foundation::{
NSAttributedString, NSDictionary, NSMutableCopying, NSPoint, NSRect, NSSize, NSString,
};
#[derive(Clone, PartialEq)]
pub struct BorderParams {
pub color: (f64, f64, f64),
pub opacity: f64,
pub width: f64,
pub radius: f64,
}
#[derive(Clone, PartialEq)]
pub struct DimParams {
pub opacity: f32,
pub color: (f64, f64, f64),
pub cutout: Option<NSRect>,
pub border: Option<BorderParams>,
}
#[derive(Debug, Clone)]
struct DimViewIvars {
opacity: f32,
dim_r: f64,
dim_g: f64,
dim_b: f64,
cutout_x: f64,
cutout_y: f64,
cutout_w: f64,
cutout_h: f64,
has_cutout: bool,
has_border: bool,
border_r: f64,
border_g: f64,
border_b: f64,
border_opacity: f64,
border_width: f64,
border_radius: f64,
}
define_class!(
#[unsafe(super(NSView))]
#[thread_kind = MainThreadOnly]
#[name = "PaneruDimView"]
#[ivars = DimViewIvars]
#[derive(Debug)]
struct DimView;
impl DimView {
#[unsafe(method(drawRect:))]
fn draw_rect(&self, _dirty_rect: NSRect) {
let ivars = self.ivars();
let bounds = self.bounds();
let dim_color = NSColor::colorWithSRGBRed_green_blue_alpha(
ivars.dim_r as CGFloat,
ivars.dim_g as CGFloat,
ivars.dim_b as CGFloat,
CGFloat::from(ivars.opacity),
);
dim_color.setFill();
NSBezierPath::fillRect(bounds);
if ivars.has_cutout {
let half = if ivars.has_border { ivars.border_width / 2.0 } else { 0.0 };
let radius = ivars.border_radius as CGFloat;
let cutout = NSRect::new(
NSPoint::new(ivars.cutout_x - half, ivars.cutout_y - half),
NSSize::new(ivars.cutout_w + ivars.border_width, ivars.cutout_h + ivars.border_width),
);
if let Some(ctx) = NSGraphicsContext::currentContext() {
ctx.setCompositingOperation(NSCompositingOperation::Clear);
let hole = NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(
cutout, radius, radius,
);
hole.fill();
ctx.setCompositingOperation(NSCompositingOperation::SourceOver);
}
if ivars.has_border {
let border_rect = NSRect::new(
NSPoint::new(ivars.cutout_x, ivars.cutout_y),
NSSize::new(ivars.cutout_w, ivars.cutout_h),
);
let path = NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(
border_rect, radius, radius,
);
path.setLineWidth(ivars.border_width as CGFloat);
let border_color = NSColor::colorWithSRGBRed_green_blue_alpha(
ivars.border_r as CGFloat,
ivars.border_g as CGFloat,
ivars.border_b as CGFloat,
ivars.border_opacity as CGFloat,
);
border_color.setStroke();
path.stroke();
}
}
}
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
}
);
impl DimView {
fn new(mtm: MainThreadMarker, frame: NSRect, params: &DimParams) -> Retained<Self> {
let (has_cutout, cx, cy, cw, ch) = params.cutout.map_or((false, 0.0, 0.0, 0.0, 0.0), |r| {
(true, r.origin.x, r.origin.y, r.size.width, r.size.height)
});
let (has_border, br, bg, bb, bo, bw, brad) =
params
.border
.as_ref()
.map_or((false, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), |b| {
(
true, b.color.0, b.color.1, b.color.2, b.opacity, b.width, b.radius,
)
});
let this = Self::alloc(mtm).set_ivars(DimViewIvars {
opacity: params.opacity,
dim_r: params.color.0,
dim_g: params.color.1,
dim_b: params.color.2,
cutout_x: cx,
cutout_y: cy,
cutout_w: cw,
cutout_h: ch,
has_cutout,
has_border,
border_r: br,
border_g: bg,
border_b: bb,
border_opacity: bo,
border_width: bw,
border_radius: brad,
});
unsafe { msg_send![super(this), initWithFrame: frame] }
}
}
fn cg_abs_to_cocoa(frame: NSRect, primary_screen_height: f64) -> NSRect {
let cocoa_y = primary_screen_height - frame.origin.y - frame.size.height;
NSRect::new(NSPoint::new(frame.origin.x, cocoa_y), frame.size)
}
fn primary_screen_height(mtm: MainThreadMarker) -> f64 {
let screens = NSScreen::screens(mtm);
let mut fallback = 0.0;
let mut first = true;
for screen in &screens {
let frame = screen.frame();
if first {
fallback = frame.size.height;
first = false;
}
if frame.origin.x == 0.0 && frame.origin.y == 0.0 {
return frame.size.height;
}
}
fallback
}
fn rects_intersect(a: NSRect, b: NSRect) -> bool {
a.origin.x < b.origin.x + b.size.width
&& b.origin.x < a.origin.x + a.size.width
&& a.origin.y < b.origin.y + b.size.height
&& b.origin.y < a.origin.y + a.size.height
}
fn make_overlay_window(mtm: MainThreadMarker, cocoa_frame: NSRect) -> Retained<NSWindow> {
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
NSWindow::alloc(mtm),
cocoa_frame,
NSWindowStyleMask::Borderless,
NSBackingStoreType::Buffered,
false,
)
};
window.setOpaque(false);
window.setBackgroundColor(Some(&NSColor::clearColor()));
window.setIgnoresMouseEvents(true);
window.setHasShadow(false);
window.setLevel(NSFloatingWindowLevel);
window.setCollectionBehavior(
NSWindowCollectionBehavior::Transient
| NSWindowCollectionBehavior::IgnoresCycle
| NSWindowCollectionBehavior::CanJoinAllSpaces
| NSWindowCollectionBehavior::Stationary
| NSWindowCollectionBehavior::FullScreenNone,
);
window
}
pub struct OverlayManager {
mtm: MainThreadMarker,
overlays: Vec<(Retained<NSWindow>, DimParams)>,
hidden: bool,
}
impl OverlayManager {
pub fn new(mtm: MainThreadMarker) -> Self {
Self {
mtm,
overlays: Vec::new(),
hidden: false,
}
}
pub fn update(
&mut self,
dim_opacity: f32,
dim_color: (f64, f64, f64),
focused_abs_cg: Option<NSRect>,
border: Option<&BorderParams>,
) {
let screen_h = primary_screen_height(self.mtm);
let screens = NSScreen::screens(self.mtm);
let focused_cocoa = focused_abs_cg.map(|cg| cg_abs_to_cocoa(cg, screen_h));
if self.overlays.len() != screens.len() {
for (window, _) in self.overlays.drain(..) {
window.orderOut(None::<&AnyObject>);
}
}
for (i, screen) in (&screens).into_iter().enumerate() {
let frame = screen.frame();
let cutout_local = focused_cocoa
.filter(|wc| rects_intersect(*wc, frame))
.map(|wc| {
NSRect::new(
NSPoint::new(
wc.origin.x - frame.origin.x,
(frame.origin.y + frame.size.height) - (wc.origin.y + wc.size.height),
),
wc.size,
)
});
let params = DimParams {
opacity: dim_opacity,
color: dim_color,
cutout: cutout_local,
border: border.cloned(),
};
if let Some((window, stored)) = self.overlays.get_mut(i) {
if *stored == params {
window.setFrame_display(frame, false);
} else {
let view = DimView::new(self.mtm, frame, ¶ms);
window.setContentView(Some(&view));
window.setFrame_display(frame, true);
*stored = params;
}
if self.hidden {
window.orderFront(None::<&AnyObject>);
}
} else {
let window = make_overlay_window(self.mtm, frame);
let view = DimView::new(self.mtm, frame, ¶ms);
window.setContentView(Some(&view));
window.orderFront(None::<&AnyObject>);
self.overlays.push((window, params));
}
}
self.hidden = false;
}
pub fn remove_all(&mut self) {
for (window, _) in self.overlays.drain(..) {
window.orderOut(None::<&AnyObject>);
}
self.hidden = false;
}
pub fn hide_all(&mut self) {
if self.hidden {
return;
}
for (window, _) in &self.overlays {
window.orderOut(None::<&AnyObject>);
}
self.hidden = true;
}
}
#[derive(Debug, Clone)]
struct FlashMessageViewIvars {
opacity: f32,
message: Retained<NSString>,
is_badge: bool,
}
define_class!(
#[unsafe(super(NSView))]
#[thread_kind = MainThreadOnly]
#[name = "PaneruFlashMessageView"]
#[ivars = FlashMessageViewIvars]
#[derive(Debug)]
struct FlashMessageView;
impl FlashMessageView {
#[unsafe(method(drawRect:))]
fn draw_rect(&self, _dirty_rect: NSRect) {
let ivars = self.ivars();
let bounds = self.bounds();
let is_badge = ivars.is_badge;
let bezel_color = NSColor::colorWithSRGBRed_green_blue_alpha(
0.12, 0.12, 0.12,
CGFloat::from(ivars.opacity * 0.88),
);
bezel_color.setFill();
let radius = if is_badge {
24.0
} else {
bounds.size.height / 2.0
};
let path = NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(
bounds, radius, radius,
);
path.fill();
let border_color = NSColor::colorWithSRGBRed_green_blue_alpha(
1.0, 1.0, 1.0,
CGFloat::from(ivars.opacity * 0.15),
);
border_color.setStroke();
path.setLineWidth(1.0);
path.stroke();
let font = if is_badge {
NSFont::boldSystemFontOfSize(bounds.size.height * 0.62)
} else {
NSFont::systemFontOfSize(30.0)
};
let color = NSColor::colorWithSRGBRed_green_blue_alpha(
1.0, 1.0, 1.0,
CGFloat::from(ivars.opacity * 0.95),
);
let paragraph_style = unsafe {
let style = NSParagraphStyle::defaultParagraphStyle().mutableCopy();
let _: () = msg_send![&style, setAlignment: 1isize]; let _: () = msg_send![&style, setLineBreakMode: 4isize]; style
};
let attr_str: Retained<NSAttributedString> = unsafe {
let font_key = NSString::from_str("NSFont");
let color_key = NSString::from_str("NSColor");
let para_key = NSString::from_str("NSParagraphStyle");
let keys = [&*font_key, &*color_key, &*para_key];
let objects = [
&*font as &AnyObject,
&*color as &AnyObject,
&*paragraph_style as &AnyObject,
];
let attributes = NSDictionary::from_slices(&keys, &objects);
let alloc = NSAttributedString::alloc();
msg_send![alloc, initWithString: &*ivars.message, attributes: &*attributes]
};
let text_size: NSSize = unsafe { msg_send![&attr_str, size] };
let text_rect = if is_badge {
NSRect::new(
NSPoint::new(
bounds.origin.x + (bounds.size.width - text_size.width) / 2.0,
bounds.origin.y + (bounds.size.height - text_size.height) / 2.0,
),
text_size,
)
} else {
let h_pad = 24.0;
let available_width = (bounds.size.width - (2.0 * h_pad)).max(1.0);
NSRect::new(
NSPoint::new(
bounds.origin.x + h_pad,
bounds.origin.y + (bounds.size.height - text_size.height) / 2.0,
),
NSSize::new(available_width, text_size.height),
)
};
unsafe {
let _: () = msg_send![&attr_str, drawInRect: text_rect];
};
}
}
);
impl FlashMessageView {
fn new(
mtm: MainThreadMarker,
frame: NSRect,
message: &str,
opacity: f32,
is_badge: bool,
) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(FlashMessageViewIvars {
opacity,
message: NSString::from_str(message),
is_badge,
});
unsafe { msg_send![super(this), initWithFrame: frame] }
}
}
pub struct FlashMessageManager {
mtm: MainThreadMarker,
window: Option<Retained<NSWindow>>,
}
impl FlashMessageManager {
pub fn new(mtm: MainThreadMarker) -> Self {
Self { mtm, window: None }
}
#[allow(clippy::cast_precision_loss)]
pub fn show(&mut self, message: &str, opacity: f32, top_right_abs_cg: NSPoint) {
let is_badge = message.chars().count() <= 2;
let screen_h = primary_screen_height(self.mtm);
let size = if is_badge {
NSSize::new(150.0, 150.0)
} else {
let font = NSFont::systemFontOfSize(30.0);
let font_key = NSString::from_str("NSFont");
let keys = [&*font_key];
let objects = [&*font as &AnyObject];
let attributes = NSDictionary::from_slices(&keys, &objects);
let msg_ns = NSString::from_str(message);
let attr_str: Retained<NSAttributedString> = unsafe {
let alloc = NSAttributedString::alloc();
msg_send![alloc, initWithString: &*msg_ns, attributes: &*attributes]
};
let text_size: NSSize = unsafe { msg_send![&attr_str, size] };
let horizontal_padding = 48.0;
let max_width = 780.0;
let min_width = 140.0;
let width = (text_size.width + horizontal_padding).clamp(min_width, max_width);
NSSize::new(width, 64.0)
};
let padding = 20.0;
let cocoa_origin_x = top_right_abs_cg.x - size.width - padding;
let cocoa_origin_y = screen_h - (top_right_abs_cg.y + size.height + padding);
let frame = NSRect::new(NSPoint::new(cocoa_origin_x, cocoa_origin_y), size);
if let Some(window) = &self.window {
let view = FlashMessageView::new(
self.mtm,
NSRect::new(NSPoint::new(0.0, 0.0), size),
message,
opacity,
is_badge,
);
window.setContentView(Some(&view));
window.setFrame_display(frame, true);
window.orderFront(None::<&AnyObject>);
} else {
let window = make_overlay_window(self.mtm, frame);
window.setLevel(NSFloatingWindowLevel + 1);
let view = FlashMessageView::new(
self.mtm,
NSRect::new(NSPoint::new(0.0, 0.0), size),
message,
opacity,
is_badge,
);
window.setContentView(Some(&view));
window.orderFront(None::<&AnyObject>);
self.window = Some(window);
}
}
pub fn remove(&mut self) {
if let Some(window) = self.window.take() {
window.orderOut(None::<&AnyObject>);
}
}
}