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}