use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use teksilo_core::AppEventPoster;
use teksilo_core::MenuItemId;
use teksilo_core::widget::EventContext;
use teksilo_core::window::TeksiloWindowId;
#[cfg(target_os = "macos")]
mod macos;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NativeCheck {
#[default]
None,
Off,
On,
Mixed,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NativeKeyEquivalent {
pub key: String,
pub command: bool,
pub shift: bool,
pub alt: bool,
pub control: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StandardMenuRole {
App,
Window,
Help,
}
#[derive(Debug, Clone, Default)]
pub struct StandardLabels {
pub title: String,
pub about: String,
pub settings: String,
pub hide: String,
pub quit: String,
pub minimize: String,
pub zoom: String,
}
#[derive(Debug, Clone)]
pub struct StandardRoutedItem {
pub id: MenuItemId,
pub key_equiv: Option<NativeKeyEquivalent>,
}
#[derive(Debug, Clone)]
pub enum NativeMenuNode {
Item {
id: MenuItemId,
title: String,
key_equiv: Option<NativeKeyEquivalent>,
enabled: bool,
check: NativeCheck,
},
Submenu {
title: String,
children: Vec<NativeMenuNode>,
},
Separator,
Standard {
role: StandardMenuRole,
labels: StandardLabels,
quit_item: Option<StandardRoutedItem>,
settings_item: Option<StandardRoutedItem>,
},
}
#[derive(Debug, Clone, Default)]
pub struct NativeMenuSnapshot {
pub roots: Vec<NativeMenuNode>,
}
#[derive(Debug, Clone, Default)]
pub struct MenuItemDelta {
pub enabled: Option<bool>,
pub check: Option<NativeCheck>,
pub title: Option<String>,
pub key_equiv: Option<Option<NativeKeyEquivalent>>,
}
pub type MenuActionFn = Rc<dyn Fn(&mut EventContext)>;
#[derive(Clone, Default)]
pub struct NativeMenuActivation {
pub intent: Option<&'static str>,
pub action: Option<MenuActionFn>,
}
impl std::fmt::Debug for NativeMenuActivation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NativeMenuActivation")
.field("intent", &self.intent)
.field("action", &self.action.as_ref().map(|_| "<closure>"))
.finish()
}
}
#[derive(Debug, Clone)]
pub struct NativeMenuEventPayload {
pub window_id_owner: TeksiloWindowId,
pub item_id: MenuItemId,
}
pub trait NativeMenuBackend {
fn set_window_menu(
&mut self,
window_id: TeksiloWindowId,
menu: NativeMenuSnapshot,
poster: Arc<dyn AppEventPoster>,
);
fn activate_window(&mut self, window_id: TeksiloWindowId);
fn clear_window(&mut self, window_id: TeksiloWindowId);
fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta);
}
impl NativeMenuBackend for Box<dyn NativeMenuBackend> {
fn set_window_menu(
&mut self,
window_id: TeksiloWindowId,
menu: NativeMenuSnapshot,
poster: Arc<dyn AppEventPoster>,
) {
(**self).set_window_menu(window_id, menu, poster)
}
fn activate_window(&mut self, window_id: TeksiloWindowId) {
(**self).activate_window(window_id)
}
fn clear_window(&mut self, window_id: TeksiloWindowId) {
(**self).clear_window(window_id)
}
fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
(**self).update_item(id, delta)
}
}
type WindowActivations = HashMap<MenuItemId, NativeMenuActivation>;
struct NativeMenuState {
backend: RefCell<Box<dyn NativeMenuBackend>>,
activations: RefCell<HashMap<TeksiloWindowId, WindowActivations>>,
}
#[derive(Clone)]
pub struct NativeMenuHandle {
inner: Rc<NativeMenuState>,
}
impl NativeMenuHandle {
pub fn new<B: NativeMenuBackend + 'static>(backend: B) -> Self {
Self {
inner: Rc::new(NativeMenuState {
backend: RefCell::new(Box::new(backend)),
activations: RefCell::new(HashMap::new()),
}),
}
}
pub fn set_window_menu(
&self,
window_id: TeksiloWindowId,
menu: NativeMenuSnapshot,
activations: HashMap<MenuItemId, NativeMenuActivation>,
poster: Arc<dyn AppEventPoster>,
) {
self.inner
.activations
.borrow_mut()
.insert(window_id, activations);
self.inner
.backend
.borrow_mut()
.set_window_menu(window_id, menu, poster);
}
pub fn activate_window(&self, window_id: TeksiloWindowId) {
self.inner.backend.borrow_mut().activate_window(window_id);
}
pub fn clear_window(&self, window_id: TeksiloWindowId) {
self.inner.activations.borrow_mut().remove(&window_id);
self.inner.backend.borrow_mut().clear_window(window_id);
}
pub fn update_item(&self, id: MenuItemId, delta: MenuItemDelta) {
self.inner.backend.borrow_mut().update_item(id, delta);
}
pub fn activation(
&self,
window_id: TeksiloWindowId,
id: MenuItemId,
) -> Option<NativeMenuActivation> {
self.inner
.activations
.borrow()
.get(&window_id)
.and_then(|m| m.get(&id).cloned())
}
}
impl std::fmt::Debug for NativeMenuHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NativeMenuHandle")
.field("windows", &self.inner.activations.borrow().len())
.finish_non_exhaustive()
}
}
#[derive(Default)]
pub struct NoopNativeMenuBackend;
impl NoopNativeMenuBackend {
pub fn new() -> Self {
Self
}
}
impl NativeMenuBackend for NoopNativeMenuBackend {
fn set_window_menu(
&mut self,
_window_id: TeksiloWindowId,
_menu: NativeMenuSnapshot,
_poster: Arc<dyn AppEventPoster>,
) {
}
fn activate_window(&mut self, _window_id: TeksiloWindowId) {}
fn clear_window(&mut self, _window_id: TeksiloWindowId) {}
fn update_item(&mut self, _id: MenuItemId, _delta: MenuItemDelta) {}
}
pub fn default_backend() -> Box<dyn NativeMenuBackend> {
#[cfg(target_os = "macos")]
{
Box::new(macos::MacOsNativeMenuBackend::new())
}
#[cfg(not(target_os = "macos"))]
{
Box::new(NoopNativeMenuBackend::new())
}
}
#[derive(Clone, Default)]
pub struct MemoryNativeMenuBackend {
inner: Rc<RefCell<MemoryRecording>>,
}
#[derive(Default)]
struct MemoryRecording {
menus: HashMap<TeksiloWindowId, NativeMenuSnapshot>,
active: Option<TeksiloWindowId>,
deltas: Vec<(MenuItemId, MenuItemDelta)>,
cleared: Vec<TeksiloWindowId>,
}
impl MemoryNativeMenuBackend {
pub fn new() -> Self {
Self::default()
}
pub fn menu_for(&self, window_id: TeksiloWindowId) -> Option<NativeMenuSnapshot> {
self.inner.borrow().menus.get(&window_id).cloned()
}
pub fn active_window(&self) -> Option<TeksiloWindowId> {
self.inner.borrow().active
}
pub fn deltas(&self) -> Vec<(MenuItemId, MenuItemDelta)> {
self.inner.borrow().deltas.clone()
}
pub fn cleared(&self) -> Vec<TeksiloWindowId> {
self.inner.borrow().cleared.clone()
}
}
impl NativeMenuBackend for MemoryNativeMenuBackend {
fn set_window_menu(
&mut self,
window_id: TeksiloWindowId,
menu: NativeMenuSnapshot,
_poster: Arc<dyn AppEventPoster>,
) {
let mut rec = self.inner.borrow_mut();
rec.menus.insert(window_id, menu);
if rec.active.is_none() {
rec.active = Some(window_id);
}
}
fn activate_window(&mut self, window_id: TeksiloWindowId) {
self.inner.borrow_mut().active = Some(window_id);
}
fn clear_window(&mut self, window_id: TeksiloWindowId) {
let mut rec = self.inner.borrow_mut();
rec.menus.remove(&window_id);
rec.cleared.push(window_id);
if rec.active == Some(window_id) {
rec.active = None;
}
}
fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
self.inner.borrow_mut().deltas.push((id, delta));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use teksilo_core::SubscriptionId;
struct NullPoster;
impl AppEventPoster for NullPoster {
fn post_subscription_event(
&self,
_sub_id: SubscriptionId,
_event: Box<dyn std::any::Any + Send>,
) {
}
fn post_external(&self, _payload: Box<dyn std::any::Any + Send>) {}
}
fn poster() -> Arc<dyn AppEventPoster> {
Arc::new(NullPoster)
}
fn win(n: u64) -> TeksiloWindowId {
TeksiloWindowId::new(n)
}
fn sample_snapshot(id: MenuItemId) -> NativeMenuSnapshot {
NativeMenuSnapshot {
roots: vec![NativeMenuNode::Submenu {
title: "File".into(),
children: vec![NativeMenuNode::Item {
id,
title: "New".into(),
key_equiv: None,
enabled: true,
check: NativeCheck::None,
}],
}],
}
}
#[test]
fn set_menu_records_snapshot_and_activations() {
let backend = MemoryNativeMenuBackend::new();
let handle = NativeMenuHandle::new(backend.clone());
let id = MenuItemId::next();
let fired = Arc::new(Mutex::new(false));
let fired2 = fired.clone();
let mut acts = HashMap::new();
acts.insert(
id,
NativeMenuActivation {
intent: Some("app.new"),
action: Some(Rc::new(move |_ctx: &mut EventContext| {
*fired2.lock().unwrap() = true;
})),
},
);
handle.set_window_menu(win(1), sample_snapshot(id), acts, poster());
assert!(backend.menu_for(win(1)).is_some());
assert_eq!(backend.active_window(), Some(win(1)));
let act = handle.activation(win(1), id).expect("activation recorded");
assert_eq!(act.intent, Some("app.new"));
assert!(act.action.is_some());
}
#[test]
fn activate_and_clear_window() {
let backend = MemoryNativeMenuBackend::new();
let handle = NativeMenuHandle::new(backend.clone());
let id = MenuItemId::next();
handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
handle.set_window_menu(
win(2),
sample_snapshot(MenuItemId::next()),
HashMap::new(),
poster(),
);
handle.activate_window(win(2));
assert_eq!(backend.active_window(), Some(win(2)));
handle.clear_window(win(2));
assert_eq!(backend.cleared(), vec![win(2)]);
assert!(handle.activation(win(2), id).is_none());
assert!(backend.menu_for(win(2)).is_none());
}
#[test]
fn update_item_records_delta() {
let backend = MemoryNativeMenuBackend::new();
let handle = NativeMenuHandle::new(backend.clone());
let id = MenuItemId::next();
handle.update_item(
id,
MenuItemDelta {
enabled: Some(false),
check: Some(NativeCheck::On),
..Default::default()
},
);
let deltas = backend.deltas();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].0, id);
assert_eq!(deltas[0].1.enabled, Some(false));
assert_eq!(deltas[0].1.check, Some(NativeCheck::On));
}
#[test]
fn noop_backend_is_inert() {
let handle = NativeMenuHandle::new(NoopNativeMenuBackend::new());
let id = MenuItemId::next();
handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
handle.activate_window(win(1));
handle.update_item(id, MenuItemDelta::default());
handle.clear_window(win(1));
assert!(handle.activation(win(1), id).is_none());
}
}