rofi/
lib.rs

1// Copyright 2020 Tibor Schneider
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! # Rofi ui manager
16//! Spawn rofi windows, and parse the result appropriately.
17//!
18//! ## Simple example
19//!
20//! ```
21//! use rofi;
22//! use std::{fs, env};
23//!
24//! let dir_entries = fs::read_dir(env::current_dir().unwrap())
25//!     .unwrap()
26//!     .map(|d| format!("{:?}", d.unwrap().path()))
27//!     .collect::<Vec<String>>();
28//!
29//! match rofi::Rofi::new(&dir_entries).run() {
30//!     Ok(choice) => println!("Choice: {}", choice),
31//!     Err(rofi::Error::Interrupted) => println!("Interrupted"),
32//!     Err(e) => println!("Error: {}", e)
33//! }
34//! ```
35//!
36//! ## Example of returning an index
37//! `rofi` can also be used to return an index of the selected item:
38//!
39//! ```
40//! use rofi;
41//! use std::{fs, env};
42//!
43//! let dir_entries = fs::read_dir(env::current_dir().unwrap())
44//!     .unwrap()
45//!     .map(|d| format!("{:?}", d.unwrap().path()))
46//!     .collect::<Vec<String>>();
47//!
48//! match rofi::Rofi::new(&dir_entries).run_index() {
49//!     Ok(element) => println!("Choice: {}", element),
50//!     Err(rofi::Error::Interrupted) => println!("Interrupted"),
51//!     Err(rofi::Error::NotFound) => println!("User input was not found"),
52//!     Err(e) => println!("Error: {}", e)
53//! }
54//! ```
55//!
56//! ## Example of using pango formatted strings
57//! `rofi` can display pango format. Here is a simple example (you have to call
58//! the `self..pango` function).
59//!
60//! ```
61//! use rofi;
62//! use rofi::pango::{Pango, FontSize};
63//! use std::{fs, env};
64//!
65//! let entries: Vec<String> = vec![
66//!     Pango::new("Option 1").size(FontSize::Small).fg_color("#666000").build(),
67//!     Pango::new("Option 2").size(FontSize::Large).fg_color("#deadbe").build(),
68//! ];
69//!
70//! match rofi::Rofi::new(&entries).pango().run() {
71//!     Ok(element) => println!("Choice: {}", element),
72//!     Err(rofi::Error::Interrupted) => println!("Interrupted"),
73//!     Err(e) => println!("Error: {}", e)
74//! }
75//! ```
76//!
77//! ## Example of using custom keyboard shortcuts with rofi
78//!
79//! ```
80//! use rofi;
81//! use std::{fs, env};
82//!
83//! let dir_entries = fs::read_dir(env::current_dir().unwrap())
84//!     .unwrap()
85//!     .map(|d| format!("{:?}", d.unwrap().path()))
86//!     .collect::<Vec<String>>();
87//! let mut r = rofi::Rofi::new(&dir_entries);
88//! match r.kb_custom(1, "Alt+n").unwrap().run() {
89//!     Ok(choice) => println!("Choice: {}", choice),
90//!     Err(rofi::Error::CustomKeyboardShortcut(exit_code)) => println!("exit code: {:?}", exit_code),
91//!     Err(rofi::Error::Interrupted) => println!("Interrupted"),
92//!     Err(e) => println!("Error: {}", e)
93//! }
94//! ```
95
96#![deny(missing_docs, missing_debug_implementations, rust_2018_idioms)]
97
98pub mod pango;
99
100use std::io::{Read, Write};
101use std::process::{Child, Command, Stdio};
102use thiserror::Error;
103
104/// # Rofi Window Builder
105/// Rofi struct for displaying user interfaces. This struct is build after the
106/// non-consuming builder pattern. You can prepare a window, and draw it
107/// multiple times without reconstruction and reallocation. You can choose to
108/// return a handle to the child process `RofiChild`, which allows you to kill
109/// the process.
110#[derive(Debug, Clone)]
111pub struct Rofi<'a, T>
112where
113    T: AsRef<str>,
114{
115    elements: &'a [T],
116    case_sensitive: bool,
117    lines: Option<usize>,
118    message: Option<String>,
119    width: Width,
120    format: Format,
121    args: Vec<String>,
122    sort: bool,
123}
124
125/// Rofi child process.
126#[derive(Debug)]
127pub struct RofiChild<T> {
128    num_elements: T,
129    p: Child,
130}
131
132impl<T> RofiChild<T> {
133    fn new(p: Child, arg: T) -> Self {
134        Self {
135            num_elements: arg,
136            p,
137        }
138    }
139    /// Kill the Rofi process
140    pub fn kill(&mut self) -> Result<(), Error> {
141        Ok(self.p.kill()?)
142    }
143}
144
145impl RofiChild<String> {
146    /// Wait for the result and return the output as a String.
147    fn wait_with_output(&mut self) -> Result<String, Error> {
148        let status = self.p.wait()?;
149        let code = status.code().ok_or(Error::IoError(std::io::Error::new(
150            std::io::ErrorKind::Interrupted,
151            "Rofi process was interrupted",
152        )))?;
153        if status.success() {
154            let mut buffer = String::new();
155            if let Some(mut reader) = self.p.stdout.take() {
156                reader.read_to_string(&mut buffer)?;
157            }
158            if buffer.ends_with('\n') {
159                buffer.pop();
160            }
161            if buffer.is_empty() {
162                Err(Error::Blank {})
163            } else {
164                Ok(buffer)
165            }
166        } else if (10..=28).contains(&code) {
167            Err(Error::CustomKeyboardShortcut(code - 9))
168        } else {
169            Err(Error::Interrupted {})
170        }
171    }
172}
173
174impl RofiChild<usize> {
175    /// Wait for the result and return the output as an usize.
176    fn wait_with_output(&mut self) -> Result<usize, Error> {
177        let status = self.p.wait()?;
178        let code = status.code().ok_or(Error::IoError(std::io::Error::new(
179            std::io::ErrorKind::Interrupted,
180            "Rofi process was interrupted",
181        )))?;
182        if status.success() {
183            let mut buffer = String::new();
184            if let Some(mut reader) = self.p.stdout.take() {
185                reader.read_to_string(&mut buffer)?;
186            }
187            if buffer.ends_with('\n') {
188                buffer.pop();
189            }
190            if buffer.is_empty() {
191                Err(Error::Blank {})
192            } else {
193                let idx: isize = buffer.parse::<isize>()?;
194                if idx < 0 || idx > self.num_elements as isize {
195                    Err(Error::NotFound {})
196                } else {
197                    Ok(idx as usize)
198                }
199            }
200        } else if (10..=28).contains(&code) {
201            Err(Error::CustomKeyboardShortcut(code - 9))
202        } else {
203            Err(Error::Interrupted {})
204        }
205    }
206}
207
208impl<'a, T> Rofi<'a, T>
209where
210    T: AsRef<str>,
211{
212    /// Generate a new, unconfigured Rofi window based on the elements provided.
213    pub fn new(elements: &'a [T]) -> Self {
214        Self {
215            elements,
216            case_sensitive: false,
217            lines: None,
218            width: Width::None,
219            format: Format::Text,
220            args: Vec::new(),
221            sort: false,
222            message: None,
223        }
224    }
225
226    /// Show the window, and return the selected string, including pango
227    /// formatting if available
228    pub fn run(&self) -> Result<String, Error> {
229        self.spawn()?.wait_with_output()
230    }
231
232    /// show the window, and return the index of the selected string This
233    /// function will overwrite any subsequent calls to `self.format`.
234    pub fn run_index(&mut self) -> Result<usize, Error> {
235        self.spawn_index()?.wait_with_output()
236    }
237
238    /// Set sort flag
239    pub fn set_sort(&mut self) -> &mut Self {
240        self.sort = true;
241        self
242    }
243
244    /// enable pango markup
245    pub fn pango(&mut self) -> &mut Self {
246        self.args.push("-markup-rows".to_string());
247        self
248    }
249
250    /// enable password mode
251    pub fn password(&mut self) -> &mut Self {
252        self.args.push("-password".to_string());
253        self
254    }
255
256    /// enable message dialog mode (-e)
257    pub fn message_only(&mut self, message: impl Into<String>) -> Result<&mut Self, Error> {
258        if !self.elements.is_empty() {
259            return Err(Error::ConfigErrorMessageAndOptions);
260        }
261        self.message = Some(message.into());
262        Ok(self)
263    }
264
265    /// Sets the number of lines.
266    /// If this function is not called, use the number of lines provided in the
267    /// elements vector.
268    pub fn lines(&mut self, l: usize) -> &mut Self {
269        self.lines = Some(l);
270        self
271    }
272
273    /// Set the width of the window (overwrite the theme settings)
274    pub fn width(&mut self, w: Width) -> Result<&mut Self, Error> {
275        w.check()?;
276        self.width = w;
277        Ok(self)
278    }
279
280    /// Sets the case sensitivity (disabled by default)
281    pub fn case_sensitive(&mut self, sensitivity: bool) -> &mut Self {
282        self.case_sensitive = sensitivity;
283        self
284    }
285
286    /// Set the prompt of the rofi window
287    pub fn prompt(&mut self, prompt: impl Into<String>) -> &mut Self {
288        self.args.push("-p".to_string());
289        self.args.push(prompt.into());
290        self
291    }
292
293    /// Set the message of the rofi window (-mesg). Only available in dmenu mode.
294    /// Docs: <https://github.com/davatorium/rofi/blob/next/doc/rofi-dmenu.5.markdown#dmenu-specific-commandline-flags>
295    pub fn message(&mut self, message: impl Into<String>) -> &mut Self {
296        self.args.push("-mesg".to_string());
297        self.args.push(message.into());
298        self
299    }
300
301    /// Set the rofi theme
302    /// This will make sure that rofi uses `~/.config/rofi/{theme}.rasi`
303    pub fn theme(&mut self, theme: Option<impl Into<String>>) -> &mut Self {
304        if let Some(t) = theme {
305            self.args.push("-theme".to_string());
306            self.args.push(t.into());
307        }
308        self
309    }
310
311    /// Set the return format of the rofi call. Default is `Format::Text`. If
312    /// you call `self.spawn_index` later, the format will be overwritten with
313    /// `Format::Index`.
314    pub fn return_format(&mut self, format: Format) -> &mut Self {
315        self.format = format;
316        self
317    }
318
319    /// Set a custom keyboard shortcut. Rofi supports up to 19 custom keyboard shortcuts.
320    ///
321    /// `id` must be in the \[1,19\] range and identifies the keyboard shortcut
322    ///
323    /// `shortcut` can be any modifiers separated by `"+"`, with a letter or number at the end.
324    /// Ex: "Control+Shift+n", "Alt+s", "Control+Alt+Shift+1
325    ///
326    /// [https://github.com/davatorium/rofi/blob/next/source/keyb.c#L211](https://github.com/davatorium/rofi/blob/next/source/keyb.c#L211)
327    pub fn kb_custom(&mut self, id: u32, shortcut: &str) -> Result<&mut Self, String> {
328        if !(1..=19).contains(&id) {
329            return Err(format!("Attempting to set custom keyboard shortcut with invalid id: {}. Valid range is: [1,19]", id));
330        }
331        self.args.push(format!("-kb-custom-{}", id));
332        self.args.push(shortcut.to_string());
333        Ok(self)
334    }
335
336    /// Returns a child process with the pre-prepared rofi window
337    /// The child will produce the exact output as provided in the elements vector.
338    pub fn spawn(&self) -> Result<RofiChild<String>, std::io::Error> {
339        Ok(RofiChild::new(self.spawn_child()?, String::new()))
340    }
341
342    /// Returns a child process with the pre-prepared rofi window.
343    /// The child will produce the index of the chosen element in the vector.
344    /// This function will overwrite any subsequent calls to `self.format`.
345    pub fn spawn_index(&mut self) -> Result<RofiChild<usize>, std::io::Error> {
346        self.format = Format::Index;
347        Ok(RofiChild::new(self.spawn_child()?, self.elements.len()))
348    }
349
350    fn spawn_child(&self) -> Result<Child, std::io::Error> {
351        let mut child = Command::new("rofi")
352            .args(match &self.message {
353                Some(msg) => vec!["-e", msg],
354                None => vec!["-dmenu"],
355            })
356            .args(&self.args)
357            .arg("-format")
358            .arg(self.format.as_arg())
359            .arg("-l")
360            .arg(match self.lines.as_ref() {
361                Some(s) => format!("{}", s),
362                None => format!("{}", self.elements.len()),
363            })
364            .arg(match self.case_sensitive {
365                true => "-case-sensitive",
366                false => "-i",
367            })
368            .args(match self.width {
369                Width::None => vec![],
370                Width::Percentage(x) => vec![
371                    "-theme-str".to_string(),
372                    format!("window {{width: {}%;}}", x),
373                ],
374                Width::Pixels(x) => vec![
375                    "-theme-str".to_string(),
376                    format!("window {{width: {}px;}}", x),
377                ],
378            })
379            .arg(match self.sort {
380                true => "-sort",
381                false => "",
382            })
383            .stdin(Stdio::piped())
384            .stdout(Stdio::piped())
385            .stderr(Stdio::piped())
386            .spawn()?;
387
388        if let Some(mut writer) = child.stdin.take() {
389            for element in self.elements {
390                writer.write_all(element.as_ref().as_bytes())?;
391                writer.write_all(b"\n")?;
392            }
393        }
394
395        Ok(child)
396    }
397}
398
399static EMPTY_OPTIONS: Vec<String> = vec![];
400
401impl<'a> Rofi<'a, String> {
402    /// Generate a new, Rofi window in "message only" mode with the given message.
403    pub fn new_message(message: impl Into<String>) -> Self {
404        let mut rofi = Self::new(&EMPTY_OPTIONS);
405        rofi.message_only(message)
406            .expect("Invariant: provided empty options so it is safe to unwrap message_only");
407        rofi
408    }
409}
410
411/// Width of the rofi window to overwrite the default width from the rogi theme.
412#[derive(Debug, Clone, Copy)]
413pub enum Width {
414    /// No width specified, use the default one from the theme
415    None,
416    /// Width in percentage of the screen, must be between 0 and 100
417    Percentage(usize),
418    /// Width in pixels, must be greater than 100
419    Pixels(usize),
420}
421
422impl Width {
423    fn check(&self) -> Result<(), Error> {
424        match self {
425            Self::Percentage(x) => {
426                if *x > 100 {
427                    Err(Error::InvalidWidth("Percentage must be between 0 and 100"))
428                } else {
429                    Ok(())
430                }
431            }
432            Self::Pixels(x) => {
433                if *x <= 100 {
434                    Err(Error::InvalidWidth("Pixels must be larger than 100"))
435                } else {
436                    Ok(())
437                }
438            }
439            _ => Ok(()),
440        }
441    }
442}
443
444/// Different modes, how rofi should return the results
445#[derive(Debug, Clone, Copy)]
446pub enum Format {
447    /// Regular text, including markup
448    #[allow(dead_code)]
449    Text,
450    /// Text, where the markup is removed
451    StrippedText,
452    /// Text with the exact user input
453    UserInput,
454    /// Index of the chosen element
455    Index,
456}
457
458impl Format {
459    fn as_arg(&self) -> &'static str {
460        match self {
461            Format::Text => "s",
462            Format::StrippedText => "p",
463            Format::UserInput => "f",
464            Format::Index => "i",
465        }
466    }
467}
468
469/// Rofi Error Type
470#[derive(Error, Debug)]
471pub enum Error {
472    /// IO Error
473    #[error("IO Error: {0}")]
474    IoError(#[from] std::io::Error),
475    /// Parse Int Error, only occurs when getting the index.
476    #[error("Parse Int Error: {0}")]
477    ParseIntError(#[from] std::num::ParseIntError),
478    /// Error returned when the user has interrupted the action
479    #[error("User interrupted the action")]
480    Interrupted,
481    /// Error returned when the user chose a blank option
482    #[error("User chose a blank line")]
483    Blank,
484    /// Error returned the width is invalid, only returned in Rofi::width()
485    #[error("Invalid width: {0}")]
486    InvalidWidth(&'static str),
487    /// Error, when the input of the user is not found. This only occurs when
488    /// getting the index.
489    #[error("User input was not found")]
490    NotFound,
491    /// Incompatible configuration: cannot specify non-empty options and message_only.
492    #[error("Can't specify non-empty options and message_only")]
493    ConfigErrorMessageAndOptions,
494    /// A custom keyboard shortcut was used
495    #[error("User used a custom keyboard shortcut")]
496    CustomKeyboardShortcut(i32),
497}
498
499#[cfg(test)]
500mod rofitest {
501    use super::*;
502    #[test]
503    fn simple_test() {
504        let options = vec!["a", "b", "c", "d"];
505        let empty_options: Vec<String> = Vec::new();
506        match Rofi::new(&options).prompt("choose c").run() {
507            Ok(ret) => assert!(ret == "c"),
508            _ => assert!(false),
509        }
510        match Rofi::new(&options).prompt("chose c").run_index() {
511            Ok(ret) => assert!(ret == 2),
512            _ => assert!(false),
513        }
514        match Rofi::new(&options)
515            .prompt("press escape")
516            .width(Width::Percentage(15))
517            .unwrap()
518            .run_index()
519        {
520            Err(Error::Interrupted) => assert!(true),
521            _ => assert!(false),
522        }
523        match Rofi::new(&options)
524            .prompt("Enter something wrong")
525            .run_index()
526        {
527            Err(Error::NotFound) => assert!(true),
528            _ => assert!(false),
529        }
530        match Rofi::new(&empty_options)
531            .prompt("Enter password")
532            .password()
533            .return_format(Format::UserInput)
534            .run()
535        {
536            Ok(ret) => assert!(ret == "password"),
537            _ => assert!(false),
538        }
539        match Rofi::new_message("A message with no input").run() {
540            Err(Error::Blank) => (), // ok
541            _ => assert!(false),
542        }
543
544        let mut r = Rofi::new(&options);
545        match r
546            .message("Press Alt+n")
547            .kb_custom(1, "Alt+n")
548            .unwrap()
549            .run()
550        {
551            Err(Error::CustomKeyboardShortcut(exit_code)) => {
552                assert_eq!(exit_code, 1)
553            }
554            _ => assert!(false),
555        }
556    }
557}