Skip to main content

i3status_rs/blocks/
menu.rs

1//! A custom menu
2//!
3//! This block allows you to quickly run a custom shell command. Left-click on this block to
4//! activate it, then scroll through configured items. Left-click on the item to run it and
5//! optionally confirm your action by left-clicking again. Right-click any time to deactivate this
6//! block.
7//!
8//! # Configuration
9//!
10//! Key | Values | Default
11//! ----|--------|--------
12//! `text` | Text that will be displayed when the block is inactive. | **Required**
13//! `items` | A list of "items". See examples below. | **Required**
14//!
15//! # Example
16//!
17//! ```toml
18//! [[block]]
19//! block = "menu"
20//! text = "\uf011"
21//! [[block.items]]
22//! display = " ->   Sleep   <-"
23//! cmd = "systemctl suspend"
24//! [[block.items]]
25//! display = " -> Power Off <-"
26//! cmd = "poweroff"
27//! confirm_msg = "Are you sure you want to power off?"
28//! [[block.items]]
29//! display = " ->  Reboot   <-"
30//! cmd = "reboot"
31//! confirm_msg = "Are you sure you want to reboot?"
32//! ```
33
34use tokio::sync::mpsc::UnboundedReceiver;
35
36use super::{BlockAction, prelude::*};
37use crate::subprocess::spawn_shell;
38
39#[derive(Deserialize, Debug)]
40#[serde(deny_unknown_fields)]
41pub struct Config {
42    pub text: String,
43    pub items: Vec<Item>,
44}
45
46#[derive(Deserialize, Debug, Clone)]
47#[serde(deny_unknown_fields)]
48pub struct Item {
49    pub display: String,
50    pub cmd: String,
51    #[serde(default)]
52    pub confirm_msg: Option<String>,
53}
54
55struct Block<'a> {
56    actions: UnboundedReceiver<BlockAction>,
57    api: &'a CommonApi,
58    output: OutputHandle,
59    text: &'a str,
60    items: &'a [Item],
61}
62
63impl Block<'_> {
64    async fn reset(&mut self) -> Result<()> {
65        self.set_text(self.text.to_owned()).await
66    }
67
68    async fn set_text(&mut self, text: String) -> Result<()> {
69        self.api.set_widget(self.output.new_text_widget(text))
70    }
71
72    async fn wait_for_click(&mut self, button: &str) -> Result<()> {
73        while self.actions.recv().await.error("channel closed")? != button {}
74        Ok(())
75    }
76
77    async fn run_menu(&mut self) -> Result<Option<Item>> {
78        let mut index = 0;
79        loop {
80            self.set_text(self.items[index].display.clone()).await?;
81            match &*self.actions.recv().await.error("channel closed")? {
82                "_up" => index += 1,
83                "_down" => index += self.items.len() + 1,
84                "_left" => return Ok(Some(self.items[index].clone())),
85                "_right" => return Ok(None),
86                _ => (),
87            }
88            index %= self.items.len();
89        }
90    }
91
92    async fn confirm(&mut self, msg: String) -> Result<bool> {
93        self.set_text(msg).await?;
94        Ok(self.actions.recv().await.as_deref() == Some("_left"))
95    }
96}
97
98pub(crate) fn prepare(_config: &Config) -> Result<Arc<BlockPlan>> {
99    // The bar text is the configured label, a menu item's display string or a
100    // confirmation message — plain text with no placeholders and no icons, so
101    // the block declares a single text output rather than a format.
102    BlockPlan::new(vec![OutputPlan::text("main")])
103}
104
105pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
106    api.set_default_actions(&[
107        (MouseButton::Left, None, "_left"),
108        (MouseButton::Right, None, "_right"),
109        (MouseButton::WheelUp, None, "_up"),
110        (MouseButton::WheelDown, None, "_down"),
111    ])?;
112
113    let mut block = Block {
114        actions: api.get_actions()?,
115        api,
116        output: plan.output("main")?,
117        text: &config.text,
118        items: &config.items,
119    };
120
121    loop {
122        block.reset().await?;
123        block.wait_for_click("_left").await?;
124        if let Some(res) = block.run_menu().await? {
125            if let Some(msg) = res.confirm_msg
126                && !block.confirm(msg).await?
127            {
128                continue;
129            }
130            spawn_shell(&res.cmd).or_error(|| format!("Failed to run '{}'", res.cmd))?;
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    use crate::block_plan::OutputKind;
140
141    fn config() -> Config {
142        Config {
143            text: "menu".into(),
144            items: Vec::new(),
145        }
146    }
147
148    #[test]
149    fn plan_declares_one_text_output_and_no_icons() {
150        let plan = prepare(&config()).unwrap();
151        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
152        assert_eq!(ids, ["main"]);
153
154        let output = plan.output("main").unwrap();
155        assert_eq!(output.output().kind(), OutputKind::Text);
156        assert_eq!(output.output().icon_placeholders().count(), 0);
157    }
158
159    #[test]
160    fn every_text_this_block_renders_carries_the_contract() {
161        let plan = prepare(&config()).unwrap();
162        let output = plan.output("main").unwrap();
163        // The three strings the block can put on the bar: the label, a menu
164        // item's display string and a confirmation message.
165        for text in ["menu", "Shut down", "Are you sure?"] {
166            let widget = output.new_text_widget(text.into());
167            assert!(widget.contract().is_some(), "{text}");
168            widget.check_contract().unwrap();
169        }
170    }
171}