git.delta.rocks / remowt / refs/commits / 6bfa3e083e52

difftreelog

feat(ui-prompt) declarative bifrost endpoint

nswuwzxtYaroslav Bolyukin2026-01-25parent: #06498c3.patch.diff
in: trunk

5 files changed

modifiedcrates/ui-prompt/Cargo.tomldiffbeforeafterboth
--- a/crates/ui-prompt/Cargo.toml
+++ b/crates/ui-prompt/Cargo.toml
@@ -5,11 +5,13 @@
 
 [dependencies]
 bifrostlink.workspace = true
-serde = "1.0.204"
+bifrostlink-macros.workspace = true
+serde.workspace = true
+serde_json.workspace = true
 thiserror = "1.0.63"
-tokio = { version = "1.39.2", features = ["io-util", "macros", "process", "rt"] }
-tracing = "0.1.40"
-zbus = { version = "4.4.0", optional = true }
+tokio = { workspace = true, features = ["io-util", "macros", "process", "rt"] }
+tracing.workspace = true
+zbus = { workspace = true, optional = true }
 
 [features]
 default = ["dbus"]
modifiedcrates/ui-prompt/src/bifrost.rsdiffbeforeafterboth
1use bifrostlink::error::ErrorT;1use bifrostlink::{Config, Rpc};
2use bifrostlink::{request, AddressT, Rpc};2use bifrostlink_macros::endpoints;
3use serde::{Deserialize, Serialize};3use serde::{Deserialize, Serialize};
44
5use crate::{Error, Prompter, Source};5use crate::{Error, Prompter, Source};
66
7pub struct BifrostPrompter<A: AddressT, E: ErrorT> {7pub struct PromptEndpoints<P>(pub P);
8 pub address: A,8
9#[endpoints(ns = 2)]
10impl<P> PromptEndpoints<P>
11where
9 pub rpc: Rpc<A, E>,12 P: Prompter + Send + Sync + 'static,
10}13{
11
12#[derive(Serialize, Deserialize)]14 #[endpoints(id = 1, cancel)]
13struct EnumRequest {15 async fn prompt_enum(
16 &self,
14 prompt: String,17 prompt: String,
15 description: String,18 description: String,
16 variants: Vec<String>,19 variants: Vec<String>,
17 source: Vec<Source>,20 source: Vec<Source>,
18}21 ) -> Result<u32, Error> {
19#[derive(Serialize, Deserialize)]22 let variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
20struct EnumResponse {23 self.0
21 value: u32,24 .prompt_enum(&prompt, &description, &variants, &source)
22}25 .await
23request!(EnumRequest => EnumResponse);26 }
2427
28 #[endpoints(id = 2, cancel)]
29 async fn prompt_text(
25#[derive(Serialize, Deserialize)]30 &self,
26struct TextRequest {
27 echo: bool,31 echo: bool,
28 prompt: String,32 prompt: String,
29 description: String,33 description: String,
30 source: Vec<Source>,34 source: Vec<Source>,
31}
32#[derive(Serialize, Deserialize)]
33struct TextResponse {
34 value: String,35 ) -> Result<String, Error> {
35}36 self.0
36request!(TextRequest => TextResponse);37 .prompt_text(echo, &prompt, &description, &source)
38 .await
39 }
3740
38#[derive(Serialize, Deserialize)]41 #[endpoints(id = 3, cancel)]
39struct DisplayRequest {42 async fn display_text(
43 &self,
40 error: bool,44 error: bool,
41 description: String,45 description: String,
42 source: Vec<Source>,46 source: Vec<Source>,
43}47 ) -> Result<(), Error> {
44request!(DisplayRequest => ());48 self.0.display_text(error, &description, &source).await
49 }
50}
4551
46impl<A: AddressT, E: ErrorT> Prompter for BifrostPrompter<A, E>52impl<C: Config> Prompter for PromptEndpointsClient<C>
47where53where
48 crate::Error: From<E>,54 Error: ToString,
49{55{
50 async fn prompt_enum(56 async fn prompt_enum(
51 &self,57 &self,
52 prompt: &str,58 prompt: &str,
53 description: &str,59 description: &str,
54 variants: &[&str],60 variants: &[&str],
55 source: &[crate::Source],61 source: &[Source],
56 ) -> crate::Result<u32> {62 ) -> crate::Result<u32> {
57 let res = self
58 .rpc
59 .request(
60 self.address.clone(),63 self.prompt_enum(
61 &EnumRequest {
62 prompt: prompt.to_owned(),64 prompt.to_owned(),
63 description: description.to_owned(),65 description.to_owned(),
64 variants: variants.into_iter().map(|v| (*v).to_owned()).collect(),66 variants.iter().map(|v| (*v).to_owned()).collect(),
65 source: source.to_vec(),67 source.to_vec(),
66 },68 )
67 )69 .await
68 .await?;70 .map_err(|e| Error::Remote(e.to_string()))?
69 Ok(res.value)
70 }71 }
7172
72 async fn prompt_text(73 async fn prompt_text(
73 &self,74 &self,
74 echo: bool,75 echo: bool,
75 prompt: &str,76 prompt: &str,
76 description: &str,77 description: &str,
77 source: &[crate::Source],78 source: &[Source],
78 ) -> crate::Result<String> {79 ) -> crate::Result<String> {
79 let res = self
80 .rpc
81 .request(
82 self.address.clone(),80 self.prompt_text(
83 &TextRequest {
84 echo,81 echo,
85 prompt: prompt.to_owned(),82 prompt.to_owned(),
86 description: description.to_owned(),83 description.to_owned(),
87 source: source.to_vec(),84 source.to_vec(),
88 },85 )
89 )86 .await
90 .await?;87 .map_err(|e| Error::Remote(e.to_string()))?
91 Ok(res.value)
92 }88 }
9389
94 async fn display_text(90 async fn display_text(
95 &self,91 &self,
96 error: bool,92 error: bool,
97 description: &str,93 description: &str,
98 source: &[crate::Source],94 source: &[Source],
99 ) -> crate::Result<()> {95 ) -> crate::Result<()> {
100 self.rpc
101 .request(
102 self.address.clone(),96 self.display_text(error, description.to_owned(), source.to_vec())
103 &DisplayRequest {97 .await
104 error,
105 description: description.to_owned(),
106 source: source.to_vec(),
107 },
108 )
109 .await?;98 .map_err(|e| Error::Remote(e.to_string()))?
110 Ok(())
111 }99 }
112}100}
113101
114pub fn handle_bifrost_prompts<102pub fn serve_prompts<P, C>(rpc: &mut Rpc<C>, prompt: P)
115 P: Prompter + Clone + 'static,103where
116 A: AddressT,
117 E: ErrorT + From<Error>,
118>(
119 rpc: &Rpc<A, E>,
120 prompt: P,
121) {
122 rpc.register_request_handler(true, {
123 let prompt = prompt.clone();
124 move |_addr, req: EnumRequest| {104 P: Prompter + Send + Sync + 'static,
125 let prompt = prompt.clone();
126 async move {
127 let i = prompt
128 .prompt_enum(
129 &req.prompt,105 C: Config,
130 &req.description,
131 &req.variants.iter().map(|v| v.as_str()).collect::<Vec<_>>(),106 C::Error: From<Error>,
132 &req.source,
133 )
134 .await?;
135
136 Ok(EnumResponse { value: i })
137 }
138 }
139 });
140 rpc.register_request_handler(true, {107{
141 let prompt = prompt.clone();
142 move |_addr, req: TextRequest| {
143 let prompt = prompt.clone();
144 async move {
145 let i = prompt
146 .prompt_text(req.echo, &req.prompt, &req.description, &req.source)108 PromptEndpoints(prompt).register_endpoints(rpc);
147 .await?;
148
149 Ok(TextResponse { value: i })
150 }
151 }
152 });109}
153 rpc.register_request_handler(true, {
154 let prompt = prompt.clone();
155 move |_addr, req: DisplayRequest| {
156 let prompt = prompt.clone();
157 async move {
158 prompt
159 .display_text(req.error, &req.description, &req.source)
160 .await?;
161
162 Ok(())
163 }
164 }
165 });
166}
167110
modifiedcrates/ui-prompt/src/dbus.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/dbus.rs
+++ b/crates/ui-prompt/src/dbus.rs
@@ -6,128 +6,130 @@
 use crate::{Error, Prompter};
 
 pub struct DbusPrompterInterface<P>(pub P);
+
 #[interface(name = "lach.PolkitInputHandler")]
 impl<P: Prompter + Send + Sync + 'static> DbusPrompterInterface<P> {
-    async fn prompt_radio(
-        &self,
-        prompt: &str,
-        description: &str,
-        source: Vec<Source>,
-    ) -> fdo::Result<bool> {
-        Ok(self.0.prompt_radio(prompt, description, &source).await?)
-    }
-    async fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: Vec<Source>,
-    ) -> fdo::Result<String> {
-        Ok(self
-            .0
-            .prompt_text(echo, prompt, description, &source)
-            .await?)
-    }
-    async fn display_text(
-        &self,
-        error: bool,
-        description: &str,
-        source: Vec<Source>,
-    ) -> fdo::Result<()> {
-        Ok(self.0.display_text(error, description, &source).await?)
-    }
+	async fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<bool> {
+		Ok(self.0.prompt_radio(prompt, description, &source).await?)
+	}
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<String> {
+		Ok(self
+			.0
+			.prompt_text(echo, prompt, description, &source)
+			.await?)
+	}
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<()> {
+		Ok(self.0.display_text(error, description, &source).await?)
+	}
 }
 
 #[proxy(interface = "lach.PolkitInputHandler")]
-trait DbusPrompter {
-    async fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> fdo::Result<u32>;
-    async fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> fdo::Result<String>;
-    async fn display_text(
-        &self,
-        error: bool,
-        description: &str,
-        source: &[Source],
-    ) -> fdo::Result<()>;
+pub trait DbusPrompter {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> fdo::Result<u32>;
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> fdo::Result<String>;
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> fdo::Result<()>;
 }
 
 impl Prompter for DbusPrompterProxy<'_> {
-    async fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> Result<u32> {
-        Ok(self
-            .prompt_enum(prompt, description, variants, source)
-            .await?)
-    }
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		Ok(self
+			.prompt_enum(prompt, description, variants, source)
+			.await?)
+	}
 
-    async fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> Result<String> {
-        Ok(self.prompt_text(echo, prompt, description, source).await?)
-    }
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		Ok(self.prompt_text(echo, prompt, description, source).await?)
+	}
 
-    async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-        Ok(self.display_text(error, description, source).await?)
-    }
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		Ok(self.display_text(error, description, source).await?)
+	}
 }
 impl BlockingPrompter for DbusPrompterProxyBlocking<'_> {
-    fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> Result<u32> {
-        Ok(self.prompt_enum(prompt, description, variants, source)?)
-    }
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		Ok(self.prompt_enum(prompt, description, variants, source)?)
+	}
 
-    fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> Result<String> {
-        Ok(self.prompt_text(echo, prompt, description, source)?)
-    }
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		Ok(self.prompt_text(echo, prompt, description, source)?)
+	}
 
-    fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-        Ok(self.display_text(error, description, source)?)
-    }
+	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		Ok(self.display_text(error, description, source)?)
+	}
 }
 
 impl From<fdo::Error> for Error {
-    fn from(value: fdo::Error) -> Self {
-        if matches!(value, fdo::Error::NoReply(_)) {
-            return Self::Cancel;
-        }
-        Self::InputError(format!("{value}"))
-    }
+	fn from(value: fdo::Error) -> Self {
+		if matches!(value, fdo::Error::NoReply(_)) {
+			return Self::Cancel;
+		}
+		Self::InputError(format!("{value}"))
+	}
 }
 impl From<Error> for fdo::Error {
-    fn from(value: Error) -> Self {
-        match value {
-            Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),
-            Error::InputError(e) => fdo::Error::Failed(e),
-        }
-    }
+	fn from(value: Error) -> Self {
+		match value {
+			Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),
+			Error::Remote(e) => fdo::Error::NoReply(format!("remote error occured: {e}")),
+			Error::InputError(e) => fdo::Error::Failed(e),
+		}
+	}
 }
modifiedcrates/ui-prompt/src/lib.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/lib.rs
+++ b/crates/ui-prompt/src/lib.rs
@@ -3,16 +3,18 @@
 use std::future::Future;
 use std::result;
 
+pub mod bifrost;
 pub mod dbus;
 pub mod rofi;
-pub mod bifrost;
 
-#[derive(thiserror::Error, Debug)]
+#[derive(thiserror::Error, Debug, serde::Serialize, serde::Deserialize)]
 pub enum Error {
-    #[error("user has cancelled input")]
-    Cancel,
-    #[error("input error: {0}")]
-    InputError(String),
+	#[error("user has cancelled input")]
+	Cancel,
+	#[error("input error: {0}")]
+	InputError(String),
+	#[error("unknown remote error: {0}")]
+	Remote(String),
 }
 
 pub type Result<T, E = Error> = result::Result<T, E>;
@@ -21,179 +23,179 @@
 #[derive(serde::Serialize, serde::Deserialize, Clone)]
 pub struct Source(pub Cow<'static, str>);
 impl fmt::Display for Source {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "<u>{}</u>", self.0)
-    }
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		write!(f, "<u>{}</u>", self.0)
+	}
 }
 
 pub trait Prompter: Send + Sync {
-    fn prompt_radio(
-        &self,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> impl Future<Output = Result<bool>> + Send {
-        let fut = self.prompt_enum(prompt, description, &["No", "Yes"], source);
-        async { fut.await.map(|v| v == 1) }
-    }
-    fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> impl Future<Output = Result<u32>> + Send;
-    fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> impl Future<Output = Result<String>> + Send;
-    fn display_text(
-        &self,
-        error: bool,
-        description: &str,
-        source: &[Source],
-    ) -> impl Future<Output = Result<()>> + Send;
+	fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<bool>> + Send {
+		let fut = self.prompt_enum(prompt, description, &["No", "Yes"], source);
+		async { fut.await.map(|v| v == 1) }
+	}
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> impl Future<Output = Result<u32>> + Send;
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<String>> + Send;
+	fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<()>> + Send;
 }
 pub trait BlockingPrompter {
-    fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool> {
-        self.prompt_enum(prompt, description, &["No", "Yes"], source)
-            .map(|v| v == 1)
-    }
-    fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> Result<u32>;
-    fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> Result<String>;
-    fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>;
+	fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool> {
+		self.prompt_enum(prompt, description, &["No", "Yes"], source)
+			.map(|v| v == 1)
+	}
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32>;
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String>;
+	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>;
 }
 impl<P> Prompter for &P
 where
-    P: Prompter,
+	P: Prompter,
 {
-    fn prompt_radio(
-        &self,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> impl Future<Output = Result<bool>> + Send {
-        (*self).prompt_radio(prompt, description, source)
-    }
+	fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<bool>> + Send {
+		(*self).prompt_radio(prompt, description, source)
+	}
 
-    fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> impl Future<Output = Result<u32>> + Send {
-        (*self).prompt_enum(prompt, description, variants, source)
-    }
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> impl Future<Output = Result<u32>> + Send {
+		(*self).prompt_enum(prompt, description, variants, source)
+	}
 
-    fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> impl Future<Output = Result<String>> + Send {
-        (*self).prompt_text(echo, prompt, description, source)
-    }
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<String>> + Send {
+		(*self).prompt_text(echo, prompt, description, source)
+	}
 
-    fn display_text(
-        &self,
-        error: bool,
-        description: &str,
-        source: &[Source],
-    ) -> impl Future<Output = Result<()>> + Send {
-        (*self).display_text(error, description, source)
-    }
+	fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<()>> + Send {
+		(*self).display_text(error, description, source)
+	}
 }
 
 pub struct PrependSourcePrompter<P> {
-    pub prompter: P,
-    pub source: Vec<Source>,
-    pub description: String,
+	pub prompter: P,
+	pub source: Vec<Source>,
+	pub description: String,
 }
 impl<P> PrependSourcePrompter<P> {
-    fn source(&self, input: &[Source]) -> Vec<Source> {
-        let mut out = self.source.clone();
-        out.extend(input.iter().cloned());
-        out
-    }
-    fn description(&self, input: &str) -> String {
-        if self.description.is_empty() {
-            input.to_owned()
-        } else if input.is_empty() {
-            self.description.to_owned()
-        } else {
-            format!("{input}\n\n{}", self.description)
-        }
-    }
+	fn source(&self, input: &[Source]) -> Vec<Source> {
+		let mut out = self.source.clone();
+		out.extend(input.iter().cloned());
+		out
+	}
+	fn description(&self, input: &str) -> String {
+		if self.description.is_empty() {
+			input.to_owned()
+		} else if input.is_empty() {
+			self.description.to_owned()
+		} else {
+			format!("{input}\n\n{}", self.description)
+		}
+	}
 }
 impl<P> Prompter for PrependSourcePrompter<P>
 where
-    P: Prompter + Sync,
+	P: Prompter + Sync,
 {
-    async fn prompt_radio(
-        &self,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> Result<bool> {
-        self.prompter
-            .prompt_radio(prompt, &self.description(description), &self.source(source))
-            .await
-    }
+	async fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<bool> {
+		self.prompter
+			.prompt_radio(prompt, &self.description(description), &self.source(source))
+			.await
+	}
 
-    async fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> Result<u32> {
-        self.prompter
-            .prompt_enum(
-                prompt,
-                dbg!(&self.description(description)),
-                variants,
-                &self.source(source),
-            )
-            .await
-    }
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		self.prompter
+			.prompt_enum(
+				prompt,
+				&self.description(description),
+				variants,
+				&self.source(source),
+			)
+			.await
+	}
 
-    async fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> Result<String> {
-        self.prompter
-            .prompt_text(
-                echo,
-                prompt,
-                &self.description(description),
-                &self.source(source),
-            )
-            .await
-    }
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		self.prompter
+			.prompt_text(
+				echo,
+				prompt,
+				&self.description(description),
+				&self.source(source),
+			)
+			.await
+	}
 
-    async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-        self.prompter
-            .display_text(error, &self.description(description), &self.source(source))
-            .await
-    }
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		self.prompter
+			.display_text(error, &self.description(description), &self.source(source))
+			.await
+	}
 }
modifiedcrates/ui-prompt/src/rofi.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/rofi.rs
+++ b/crates/ui-prompt/src/rofi.rs
@@ -6,195 +6,203 @@
 
 use crate::{Error, Prompter, Result, Source};
 
+#[derive(Clone)]
 pub struct RofiPrompter;
 
 fn fixup_prompt(prompt: &str) -> &str {
-    // Rofi always appends such suffix
-    prompt.strip_suffix(": ").unwrap_or(prompt)
+	// Rofi always appends such suffix
+	prompt.strip_suffix(": ").unwrap_or(prompt)
 }
 
+fn rofi_command() -> Command {
+	Command::new(option_env!("ROFI").unwrap_or("rofi"))
+}
+
 impl Prompter for RofiPrompter {
-    async fn prompt_enum(
-        &self,
-        prompt: &str,
-        description: &str,
-        variants: &[&str],
-        source: &[Source],
-    ) -> Result<u32> {
-        trace!("rofi radio");
-        let mut cmd = Command::new("rofi");
-        let mesg = if source.is_empty() {
-            description.to_owned()
-        } else {
-            let mut out = format!("{description}\n\n<b>Requested on ",);
-            for (i, s) in source.iter().enumerate() {
-                if i != 0 {
-                    out.push_str(" -> ");
-                }
-                out.push_str(&s.to_string());
-            }
-            out.push_str("</b>");
-            out
-        };
-        cmd.args([
-            "-dmenu",
-            "-mesg",
-            &mesg,
-            "-sync",
-            "-only-match",
-            "-p",
-            fixup_prompt(prompt),
-            "-format",
-            "i",
-            "-markup-rows",
-        ]);
-        cmd.stdin(Stdio::piped());
-        cmd.stdout(Stdio::piped());
-        cmd.kill_on_drop(true);
-        let mut child = cmd
-            .spawn()
-            .map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		trace!("rofi radio");
+		let mut cmd = rofi_command();
+		let mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Requested on ",);
+			for (i, s) in source.iter().enumerate() {
+				if i != 0 {
+					out.push_str(" -> ");
+				}
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		cmd.args([
+			"-dmenu",
+			"-mesg",
+			&mesg,
+			"-sync",
+			"-only-match",
+			"-p",
+			fixup_prompt(prompt),
+			"-format",
+			"i",
+			"-markup-rows",
+		]);
+		cmd.stdin(Stdio::piped());
+		cmd.stdout(Stdio::piped());
+		cmd.kill_on_drop(true);
+		let mut child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
 
-        let mut stdin = child.stdin.take().expect("stdin is piped");
-        for var in variants {
-            stdin
-                .write_all(var.replace('\n', " ").as_bytes())
-                .await
-                .map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
-            stdin
-                .write_all(b"\n")
-                .await
-                .map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
-        }
-        // write_all already flushes, just to be sure.
-        let _ = stdin.flush().await;
-        drop(stdin);
+		let mut stdin = child.stdin.take().expect("stdin is piped");
+		for var in variants {
+			stdin
+				.write_all(var.replace('\n', " ").as_bytes())
+				.await
+				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
+			stdin
+				.write_all(b"\n")
+				.await
+				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
+		}
+		// write_all already flushes, just to be sure.
+		let _ = stdin.flush().await;
+		drop(stdin);
 
-        let out = child
-            .wait_with_output()
-            .await
-            .map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
-        let stdout = out
-            .stdout
-            .strip_suffix(b"\n")
-            .unwrap_or(&out.stdout)
-            .to_owned();
+		let out = child
+			.wait_with_output()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+		let stdout = out
+			.stdout
+			.strip_suffix(b"\n")
+			.unwrap_or(&out.stdout)
+			.to_owned();
 
-        let id: u32 = String::from_utf8(stdout)
-            .map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?
-            .parse()
-            .map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?;
-        if id as usize >= variants.len() {
-            return Err(Error::InputError("invalid rofi response".to_owned()));
-        }
+		let id: u32 = String::from_utf8(stdout)
+			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?
+			.parse()
+			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?;
+		if id as usize >= variants.len() {
+			return Err(Error::InputError("invalid rofi response".to_owned()));
+		}
 
-        Ok(id)
-    }
+		Ok(id)
+	}
 
-    async fn prompt_text(
-        &self,
-        echo: bool,
-        prompt: &str,
-        description: &str,
-        source: &[Source],
-    ) -> Result<String> {
-        trace!("rofi text");
-        let mut cmd = Command::new("rofi");
-        let mesg = if source.is_empty() {
-            description.to_owned()
-        } else {
-            let mut out = format!("{description}\n\n<b>Requested on ",);
-            for (i, s) in source.iter().enumerate() {
-                if i != 0 {
-                    out.push_str(" -> ");
-                }
-                out.push_str(&s.to_string());
-            }
-            out.push_str("</b>");
-            out
-        };
-        cmd.args(["-dmenu", "-mesg", &mesg, "-p", fixup_prompt(prompt)]);
-        if !echo {
-            cmd.arg("-password");
-        }
-        cmd.stdin(Stdio::null());
-        cmd.stdout(Stdio::piped());
-        cmd.kill_on_drop(true);
-        let child = cmd
-            .spawn()
-            .map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		trace!("rofi text");
+		let mut cmd = rofi_command();
+		let mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Requested on ",);
+			for (i, s) in source.iter().enumerate() {
+				if i != 0 {
+					out.push_str(" -> ");
+				}
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		cmd.args(["-dmenu", "-mesg", &mesg, "-p", fixup_prompt(prompt)]);
+		if !echo {
+			cmd.arg("-password");
+		}
+		cmd.stdin(Stdio::null());
+		cmd.stdout(Stdio::piped());
+		cmd.kill_on_drop(true);
+		let child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
 
-        let out = child
-            .wait_with_output()
-            .await
-            .map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
-        let stdout = out
-            .stdout
-            .strip_suffix(b"\n")
-            .unwrap_or(&out.stdout)
-            .to_owned();
+		let out = child
+			.wait_with_output()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+		let stdout = out
+			.stdout
+			.strip_suffix(b"\n")
+			.unwrap_or(&out.stdout)
+			.to_owned();
 
-        Ok(String::from_utf8_lossy(&stdout).to_string())
-    }
+		Ok(String::from_utf8_lossy(&stdout).to_string())
+	}
 
-    async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-        trace!("rofi display");
-        let mut cmd = Command::new("rofi");
-        let mut mesg = if source.is_empty() {
-            description.to_owned()
-        } else {
-            let mut out = format!("{description}\n\n<b>Coming from ",);
-            for s in source.iter() {
-                out.push_str(&s.to_string());
-            }
-            out.push_str("</b>");
-            out
-        };
-        if error {
-            mesg.insert_str(0, "<span color=\"red\">");
-            mesg.push_str("</span>");
-        }
-        cmd.args(["-e", &mesg, "-markup"]);
-        cmd.stdin(Stdio::null());
-        cmd.stdout(Stdio::null());
-        cmd.kill_on_drop(true);
-        let mut child = cmd
-            .spawn()
-            .map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		trace!("rofi display");
+		let mut cmd = rofi_command();
+		let mut mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Coming from ",);
+			for s in source.iter() {
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		if error {
+			mesg.insert_str(0, "<span color=\"red\">");
+			mesg.push_str("</span>");
+		}
+		cmd.args(["-e", &mesg, "-markup"]);
+		cmd.stdin(Stdio::null());
+		cmd.stdout(Stdio::null());
+		cmd.kill_on_drop(true);
+		let mut child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
 
-        child
-            .wait()
-            .await
-            .map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+		child
+			.wait()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
 
-        Ok(())
-    }
+		Ok(())
+	}
 }
 
 #[cfg(test)]
 mod tests {
-    use std::borrow::Cow;
+	use std::borrow::Cow;
 
-    use crate::rofi::RofiPrompter;
-    use crate::{PrependSourcePrompter, Prompter as _, Source};
+	use crate::rofi::RofiPrompter;
+	use crate::{PrependSourcePrompter, Prompter as _, Source};
 
-    #[tokio::test]
-    async fn test() {
-        let prompter = PrependSourcePrompter {
-            prompter: RofiPrompter,
-            source: vec![Source(Cow::Borrowed("ssh"))],
-        };
-        prompter
-            .prompt_radio("Enable", "Polkit needs access", &[])
-            .await
-            .expect("rofi");
-        prompter
-            .prompt_text(false, "Password", "Polkit needs access", &[])
-            .await
-            .expect("rofi");
-        prompter
-            .display_text(true, "Polkit needs access", &[])
-            .await
-            .expect("rofi");
-    }
+	// #[tokio::test]
+	#[tokio::test]
+	#[ignore = "interactive"]
+	async fn test() {
+		let prompter = PrependSourcePrompter {
+			prompter: RofiPrompter,
+			description: "test".to_owned(),
+			source: vec![Source(Cow::Borrowed("ssh"))],
+		};
+		prompter
+			.prompt_radio("Enable", "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+		prompter
+			.prompt_text(false, "Password", "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+		prompter
+			.display_text(true, "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+	}
 }