bitbar/
lib.rs

1#![deny(missing_docs, rust_2018_idioms, unused, unused_crate_dependencies, unused_import_braces, unused_qualifications, warnings)]
2#![forbid(unsafe_code)]
3
4#![cfg_attr(docsrs, feature(doc_cfg))]
5
6//! This is `bitbar`, a library crate which includes helpers for writing BitBar plugins in Rust. BitBar is a system that makes it easy to add menus to the macOS menu bar. There are two apps implementing the BitBar system: [SwiftBar](https://swiftbar.app/) and [xbar](https://xbarapp.com/). This crate supports both of them, as well as [the discontinued original BitBar app](https://github.com/matryer/xbar/tree/a595e3bdbb961526803b60be6fd32dd0c667b6ec).
7//!
8//! There are two main entry points:
9//!
10//! * It's recommended to use the [`main`](crate::main) attribute and write a `main` function that returns a [`Menu`](crate::Menu), along with optional [`command`](crate::command) functions and an optional [`fallback_command`](crate::fallback_command) function.
11//! * For additional control over your plugin's behavior, you can directly [`Display`](std::fmt::Display) a [`Menu`](crate::Menu).
12//!
13//! BitBar plugins must have filenames of the format `name.duration.extension`, even though macOS binaries normally don't have extensions. You will have to add an extension, e.g. `.o`, to make Rust binaries work as plugins.
14//!
15//! # Example
16//!
17//! ```rust
18//! use bitbar::{Menu, MenuItem};
19//!
20//! #[bitbar::main]
21//! fn main() -> Menu {
22//!     Menu(vec![
23//!         MenuItem::new("Title"),
24//!         MenuItem::Sep,
25//!         MenuItem::new("Menu Item"),
26//!     ])
27//! }
28//! ```
29//!
30//! Or:
31//!
32//! ```rust
33//! use bitbar::{Menu, MenuItem};
34//!
35//! fn main() {
36//!     print!("{}", Menu(vec![
37//!         MenuItem::new("Title"),
38//!         MenuItem::Sep,
39//!         MenuItem::new("Menu Item"),
40//!     ]));
41//! }
42//! ```
43//!
44//! There is also [a list of real-world examples](https://github.com/fenhl/rust-bitbar#example-plugins).
45
46use {
47    std::{
48        borrow::Cow,
49        collections::BTreeMap,
50        convert::TryInto,
51        fmt,
52        iter::FromIterator,
53        process,
54        vec,
55    },
56    if_chain::if_chain,
57    url::Url,
58};
59#[cfg(feature = "tokio")] use std::{
60    future::Future,
61    pin::Pin,
62};
63pub use {
64    bitbar_derive::{
65        command,
66        fallback_command,
67        main,
68    },
69    crate::flavor::Flavor,
70};
71#[cfg(feature = "tokio")] #[doc(hidden)] pub use tokio;
72
73pub mod attr;
74pub mod flavor;
75
76/// A menu item that's not a separator.
77#[derive(Debug, Default)]
78pub struct ContentItem {
79    /// This menu item's main content text.
80    ///
81    /// Any `|` in the text will be displayed as `¦`, and any newlines will be displayed as spaces.
82    pub text: String,
83    /// This menu item's alternate-mode menu item or submenu.
84    pub extra: Option<attr::Extra>,
85    /// Corresponds to BitBar's `href=` parameter.
86    pub href: Option<Url>,
87    /// Corresponds to BitBar's `color=` parameter.
88    pub color: Option<attr::Color>,
89    /// Corresponds to BitBar's `font=` parameter.
90    pub font: Option<String>,
91    /// Corresponds to BitBar's `size=` parameter.
92    pub size: Option<usize>,
93    /// Corresponds to BitBar's `bash=`, `terminal=`, `param1=`, etc. parameters.
94    pub command: Option<attr::Command>,
95    /// Corresponds to BitBar's `refresh=` parameter.
96    pub refresh: bool,
97    /// Corresponds to BitBar's `image=` or `templateImage=` parameter.
98    pub image: Option<attr::Image>,
99    /// Parameters for flavor-specific features.
100    pub flavor_attrs: Option<flavor::Attrs>,
101}
102
103impl ContentItem {
104    /// Returns a new menu item with the given text.
105    ///
106    /// Any `|` in the text will be displayed as `¦`, and any newlines will be displayed as spaces.
107    pub fn new(text: impl ToString) -> ContentItem {
108        ContentItem {
109            text: text.to_string(),
110            ..ContentItem::default()
111        }
112    }
113
114    /// Adds a submenu to this menu item.
115    pub fn sub(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
116        self.extra = Some(attr::Extra::Submenu(Menu::from_iter(items)));
117        self
118    }
119
120    /// Adds a clickable link to this menu item.
121    pub fn href(mut self, href: impl attr::IntoUrl) -> Result<Self, url::ParseError> {
122        self.href = Some(href.into_url()?);
123        Ok(self)
124    }
125
126    /// Sets this menu item's text color. Alpha channel is ignored.
127    pub fn color<C: TryInto<attr::Color>>(mut self, color: C) -> Result<Self, C::Error> {
128        self.color = Some(color.try_into()?);
129        Ok(self)
130    }
131
132    /// Sets this menu item's text font.
133    pub fn font(mut self, font: impl ToString) -> Self {
134        self.font = Some(font.to_string());
135        self
136    }
137
138    /// Sets this menu item's font size.
139    pub fn size(mut self, size: usize) -> Self {
140        self.size = Some(size);
141        self
142    }
143
144    /// Make this menu item run the given command when clicked.
145    pub fn command<C: TryInto<attr::Command>>(mut self, cmd: C) -> Result<Self, C::Error> {
146        self.command = Some(cmd.try_into()?);
147        Ok(self)
148    }
149
150    /// Causes the BitBar plugin to be refreshed when this menu item is clicked.
151    pub fn refresh(mut self) -> Self {
152        self.refresh = true;
153        self
154    }
155
156    /// Adds an alternate menu item, which is shown instead of this one as long as the option key ⌥ is held.
157    pub fn alt(mut self, alt: impl Into<ContentItem>) -> Self {
158        self.extra = Some(attr::Extra::Alternate(Box::new(alt.into())));
159        self
160    }
161
162    /// Adds a template image to this menu item.
163    pub fn template_image<T: TryInto<attr::Image>>(mut self, img: T) -> Result<Self, T::Error> {
164        self.image = Some(attr::Image::template(img)?);
165        Ok(self)
166    }
167
168    /// Adds an image to this menu item. The image will not be considered a template image unless specified as such by the `img` parameter.
169    pub fn image<T: TryInto<attr::Image>>(mut self, img: T) -> Result<Self, T::Error> {
170        self.image = Some(img.try_into()?);
171        Ok(self)
172    }
173
174    fn render(&self, f: &mut fmt::Formatter<'_>, is_alt: bool) -> fmt::Result {
175        // main text
176        write!(f, "{}", self.text.replace('|', "¦").replace('\n', " "))?;
177        // parameters
178        let mut rendered_params = BTreeMap::default();
179        if let Some(ref href) = self.href {
180            rendered_params.insert(Cow::Borrowed("href"), Cow::Borrowed(href.as_ref()));
181        }
182        if let Some(ref color) = self.color {
183            rendered_params.insert(Cow::Borrowed("color"), Cow::Owned(color.to_string()));
184        }
185        if let Some(ref font) = self.font {
186            rendered_params.insert(Cow::Borrowed("font"), Cow::Borrowed(font));
187        }
188        if let Some(size) = self.size {
189            rendered_params.insert(Cow::Borrowed("size"), Cow::Owned(size.to_string()));
190        }
191        if let Some(ref cmd) = self.command {
192            //TODO (xbar) prefer “shell” over “bash”
193            rendered_params.insert(Cow::Borrowed("bash"), Cow::Borrowed(&cmd.params.cmd));
194            for (i, param) in cmd.params.params.iter().enumerate() {
195                rendered_params.insert(Cow::Owned(format!("param{}", i + 1)), Cow::Borrowed(param));
196            }
197            if !cmd.terminal {
198                rendered_params.insert(Cow::Borrowed("terminal"), Cow::Borrowed("false"));
199            }
200        }
201        if self.refresh {
202            rendered_params.insert(Cow::Borrowed("refresh"), Cow::Borrowed("true"));
203        }
204        if is_alt {
205            rendered_params.insert(Cow::Borrowed("alternate"), Cow::Borrowed("true"));
206        }
207        if let Some(ref img) = self.image {
208            rendered_params.insert(Cow::Borrowed(if img.is_template { "templateImage" } else { "image" }), Cow::Borrowed(&img.base64_data));
209        }
210        if let Some(ref flavor_attrs) = self.flavor_attrs {
211            flavor_attrs.render(&mut rendered_params);
212        }
213        if !rendered_params.is_empty() {
214            write!(f, " |")?;
215            for (name, value) in rendered_params {
216                let quoted_value = if value.contains(' ') {
217                    Cow::Owned(format!("\"{}\"", value))
218                } else {
219                    value
220                }; //TODO check for double quotes in value, fall back to single quotes? (test if BitBar supports these first)
221                write!(f, " {}={}", name, quoted_value)?;
222            }
223        }
224        writeln!(f)?;
225        // additional items
226        match &self.extra {
227            Some(attr::Extra::Alternate(ref alt)) => { alt.render(f, true)?; }
228            Some(attr::Extra::Submenu(ref sub)) => {
229                let sub_fmt = format!("{}", sub);
230                for line in sub_fmt.lines() {
231                    writeln!(f, "--{}", line)?;
232                }
233            }
234            None => {}
235        }
236        Ok(())
237    }
238}
239
240impl fmt::Display for ContentItem {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        self.render(f, false)
243    }
244}
245
246/// A menu item can either be a separator or a content item.
247#[derive(Debug)]
248pub enum MenuItem {
249    /// A content item, i.e. any menu item that's not a separator.
250    Content(ContentItem),
251    /// A separator bar.
252    Sep
253}
254
255impl MenuItem {
256    /// Returns a new menu item with the given text. See `ContentItem::new` for details.
257    pub fn new(text: impl fmt::Display) -> MenuItem {
258        MenuItem::Content(ContentItem::new(text))
259    }
260}
261
262impl Default for MenuItem {
263    fn default() -> MenuItem {
264        MenuItem::Content(ContentItem::default())
265    }
266}
267
268impl From<ContentItem> for MenuItem {
269    fn from(i: ContentItem) -> MenuItem {
270        MenuItem::Content(i)
271    }
272}
273
274impl fmt::Display for MenuItem {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            MenuItem::Content(content) => write!(f, "{}", content),
278            MenuItem::Sep => writeln!(f, "---")
279        }
280    }
281}
282
283/// A BitBar menu.
284///
285/// Usually constructed by calling [`collect`](https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect) on an [`Iterator`](https://doc.rust-lang.org/std/iter/trait.Iterator.html) of `MenuItem`s.
286#[derive(Debug, Default)]
287pub struct Menu(pub Vec<MenuItem>);
288
289impl Menu {
290    /// Adds a menu item to the bottom of the menu.
291    pub fn push(&mut self, item: impl Into<MenuItem>) {
292        self.0.push(item.into());
293    }
294}
295
296impl<A: Into<MenuItem>> FromIterator<A> for Menu {
297    fn from_iter<T: IntoIterator<Item = A>>(iter: T) -> Menu {
298        Menu(iter.into_iter().map(Into::into).collect())
299    }
300}
301
302impl<A: Into<MenuItem>> Extend<A> for Menu {
303    fn extend<T: IntoIterator<Item = A>>(&mut self, iter: T) {
304        self.0.extend(iter.into_iter().map(Into::into))
305    }
306}
307
308impl IntoIterator for Menu {
309    type Item = MenuItem;
310    type IntoIter = vec::IntoIter<MenuItem>;
311
312    fn into_iter(self) -> vec::IntoIter<MenuItem> { self.0.into_iter() }
313}
314
315/// This provides the main functionality of this crate: rendering a BitBar plugin.
316///
317/// Note that the output this generates already includes a trailing newline, so it should be used with `print!` instead of `println!`.
318impl fmt::Display for Menu {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        for menu_item in &self.0 {
321            write!(f, "{}", menu_item)?;
322        }
323        Ok(())
324    }
325}
326
327/// Members of this trait can be returned from a main function annotated with [`main`].
328pub trait MainOutput {
329    /// Displays this value as a menu, using the given template image in case of an error.
330    fn main_output(self, error_template_image: Option<attr::Image>);
331}
332
333impl<T: Into<Menu>> MainOutput for T {
334    fn main_output(self, _: Option<attr::Image>) {
335        print!("{}", self.into());
336    }
337}
338
339/// In the `Err` case, the menu will be prefixed with a menu item displaying the `error_template_image` and the text `?`.
340impl<T: MainOutput, E: MainOutput> MainOutput for Result<T, E> {
341    fn main_output(self, error_template_image: Option<attr::Image>) {
342        match self {
343            Ok(x) => x.main_output(error_template_image),
344            Err(e) => {
345                let mut header = ContentItem::new("?");
346                if let Some(error_template_image) = error_template_image {
347                    header = match header.template_image(error_template_image) {
348                        Ok(header) => header,
349                        Err(never) => match never {},
350                    };
351                }
352                print!("{}", Menu(vec![header.into(), MenuItem::Sep]));
353                e.main_output(None);
354            }
355        }
356    }
357}
358
359#[cfg(feature = "tokio")]
360#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
361/// Members of this trait can be returned from a main function annotated with [`main`].
362pub trait AsyncMainOutput<'a> {
363    /// Displays this value as a menu, using the given template image in case of an error.
364    fn main_output(self, error_template_image: Option<attr::Image>) -> Pin<Box<dyn Future<Output = ()> + 'a>>;
365}
366
367#[cfg(feature = "tokio")]
368#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
369impl<'a, T: MainOutput + 'a> AsyncMainOutput<'a> for T {
370    fn main_output(self, error_template_image: Option<attr::Image>) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
371        Box::pin(async move {
372            MainOutput::main_output(self, error_template_image);
373        })
374    }
375}
376
377/// Members of this trait can be returned from a subcommand function annotated with [`command`] or [`fallback_command`].
378pub trait CommandOutput {
379    /// Reports any errors in this command output as macOS notifications.
380    fn report(self, cmd_name: &str);
381}
382
383impl CommandOutput for () {
384    fn report(self, _: &str) {}
385}
386
387impl<T: CommandOutput, E: fmt::Debug + fmt::Display> CommandOutput for Result<T, E> {
388    fn report(self, cmd_name: &str) {
389        match self {
390            Ok(x) => x.report(cmd_name),
391            Err(e) => {
392                notify_error(&format!("{}: {}", cmd_name, e), &format!("{e:?}"));
393                process::exit(1);
394            }
395        }
396    }
397}
398
399#[doc(hidden)] pub fn notify(body: impl fmt::Display) { // used in proc macro
400    if_chain! {
401        if let Flavor::SwiftBar(swiftbar) = Flavor::check();
402        if let Ok(notification) = flavor::swiftbar::Notification::new(swiftbar);
403        then {
404            let _ = notification
405                .title(env!("CARGO_PKG_NAME"))
406                .body(body.to_string())
407                .send();
408        } else {
409            #[cfg(target_os = "macos")] {
410                let _ = notify_rust::set_application(&notify_rust::get_bundle_identifier_or_default("BitBar"));
411                let _ = notify_rust::Notification::default()
412                    .summary(&env!("CARGO_PKG_NAME"))
413                    .sound_name("Funky")
414                    .body(&body.to_string())
415                    .show();
416            }
417            #[cfg(not(target_os = "macos"))] {
418                eprintln!("{body}");
419            }
420        }
421    }
422}
423
424#[doc(hidden)] pub fn notify_error(display: &str, debug: &str) { // used in proc macro
425    if_chain! {
426        if let Flavor::SwiftBar(swiftbar) = Flavor::check();
427        if let Ok(notification) = flavor::swiftbar::Notification::new(swiftbar);
428        then {
429            let _ = notification
430                .title(env!("CARGO_PKG_NAME"))
431                .subtitle(display)
432                .body(format!("debug: {debug}"))
433                .send();
434        } else {
435            #[cfg(target_os = "macos")] {
436                let _ = notify_rust::set_application(&notify_rust::get_bundle_identifier_or_default("BitBar"));
437                let _ = notify_rust::Notification::default()
438                    .summary(display)
439                    .sound_name("Funky")
440                    .body(&format!("debug: {debug}"))
441                    .show();
442            }
443            #[cfg(not(target_os = "macos"))] {
444                eprintln!("{display}");
445                eprintln!("debug: {debug}");
446            }
447        }
448    }
449}