i3status_rs/blocks/
menu.rs1use 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 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 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}