Skip to main content

hypr_cycle/
service.rs

1use anyhow::Result;
2use clap::Parser;
3
4use crate::args::Args;
5use crate::connection::HyprlandClient;
6use crate::domain::{Direction, OwnedMonitor, OwnedWorkspace};
7
8/// Represents the total functionality of the program.
9/// It can inspect the connected monitors, the extant workspaces,
10/// and can switch between workspaces.
11pub struct HyprCycle {
12    connection: Box<dyn HyprlandClient>,
13}
14
15impl HyprCycle {
16    pub fn parse_args() -> Args {
17        Args::parse()
18    }
19
20    /// The connection can be real or a mock object, as seen in the tests
21    /// in `src/service.rs`.
22    pub fn new(connection: Box<dyn HyprlandClient>) -> HyprCycle {
23        HyprCycle { connection }
24    }
25
26    /// This function builds a version of the service backed by a real
27    /// HyprlandConnection. It's just for convenience to keep main() clean.
28    pub fn real() -> Result<HyprCycle> {
29        let conn = hyprrust::HyprlandConnection::current().map_err(anyhow::Error::new)?;
30        let client = crate::connection::RealHyprlandClient::new(conn);
31        Ok(HyprCycle::new(Box::new(client)))
32    }
33
34    /// In Hyprland, only one monitor can be in focus at a time.
35    /// This function returns that monitor.
36    pub fn get_focused_monitor(&self) -> Result<OwnedMonitor> {
37        let monitors = self.connection.get_monitors()?;
38        let monitor = monitors
39            .into_iter()
40            .find(|m| m.focused())
41            .ok_or_else(|| anyhow::anyhow!("No focused monitor found"))?;
42        Ok(monitor)
43    }
44
45    /// Returns a sorted list of the workspaces bound to the provided monitor.
46    /// Throws an error if the provided monitor doesn't have any workspaces
47    /// bound to it.
48    pub fn get_workspaces_for_monitor(
49        &self,
50        monitor: &OwnedMonitor,
51    ) -> Result<Vec<OwnedWorkspace>> {
52        let workspaces = self.connection.get_workspaces()?;
53        let mut workspaces_for_monitor: Vec<OwnedWorkspace> = workspaces
54            .into_iter()
55            .filter(|w| w.monitor_name().eq(monitor.name()) && w.visible())
56            .collect();
57        if workspaces_for_monitor.is_empty() {
58            return Err(anyhow::anyhow!(
59                "No workspaces found for monitor: {}",
60                monitor.name()
61            ));
62        }
63        workspaces_for_monitor.sort();
64        Ok(workspaces_for_monitor)
65    }
66
67    /// Returns the workspace that's active on the monitor that's in focus
68    pub fn get_current_workspace(&self) -> Result<OwnedWorkspace> {
69        let focused_monitor = self.get_focused_monitor()?;
70        let active_workspace = focused_monitor.active_workspace();
71        Ok(active_workspace)
72    }
73
74    /// The index of the sorted list of workspaces tells us where to
75    /// target the upcoming workspace switch.
76    pub fn get_target_workspace(&self, direction: Direction) -> Result<OwnedWorkspace> {
77        let monitor = &self.get_focused_monitor()?;
78        let workspaces = &self.get_workspaces_for_monitor(monitor)?;
79        let current_ws = &self.get_current_workspace()?;
80
81        let idx = workspaces
82            .iter()
83            .position(|w| w == current_ws)
84            .ok_or_else(|| anyhow::anyhow!("Current workspace not found"))?;
85        let len = workspaces.len();
86
87        let next_idx = match direction {
88            Direction::Next => (idx + 1) % len,
89            Direction::Previous => (idx + len - 1) % len,
90        };
91        Ok(workspaces[next_idx].to_owned())
92    }
93
94    pub fn switch_to_workspace(&self, target: &OwnedWorkspace) -> Result<()> {
95        self.connection.go_to_workspace(target.id())?;
96        Ok(())
97    }
98}
99
100#[cfg(test)]
101pub mod fixtures {
102    use crate::domain::{OwnedMonitor, OwnedWorkspace};
103
104    pub fn ws(id: i64, mon: &str) -> OwnedWorkspace {
105        OwnedWorkspace::new(id, mon.to_string())
106    }
107
108    pub fn mon(name: &str, id: i64, focused: bool, active_id: i64) -> OwnedMonitor {
109        OwnedMonitor::new(name.to_string(), id, focused, ws(active_id, name))
110    }
111
112    pub fn monitors() -> Vec<OwnedMonitor> {
113        vec![
114            mon("eDP-1", 1, true, 1), //active monitor
115            mon("HDMI-1", 2, false, 3),
116        ]
117    }
118
119    pub fn workspaces() -> Vec<OwnedWorkspace> {
120        vec![
121            ws(-97, "eDP-1"), //hidden workspace ("scratch")
122            ws(1, "eDP-1"),
123            ws(2, "eDP-1"),
124            ws(3, "HDMI-1"),
125        ]
126    }
127}
128
129#[cfg(test)]
130mod tests {
131
132    use super::*;
133    use crate::connection::MockHyprlandClient;
134
135    mod helpers {
136        use super::super::*;
137        use crate::connection::MockHyprlandClient;
138        use anyhow::Context;
139
140        pub fn mock_service_with(conn: MockHyprlandClient) -> HyprCycle {
141            HyprCycle::new(Box::new(conn))
142        }
143
144        pub fn mock_service() -> HyprCycle {
145            let mut conn = MockHyprlandClient::new();
146            conn.expect_get_monitors()
147                .returning(|| Ok(fixtures::monitors()));
148            conn.expect_get_workspaces()
149                .returning(|| Ok(fixtures::workspaces()));
150            mock_service_with(conn)
151        }
152
153        pub fn visible_for_monitor(
154            ws: Vec<OwnedWorkspace>,
155            monitor: &OwnedMonitor,
156        ) -> Vec<OwnedWorkspace> {
157            ws.into_iter()
158                .filter(|w| w.visible() && w.monitor_name() == monitor.name())
159                .collect()
160        }
161
162        pub fn focused_monitor(monitors: Vec<OwnedMonitor>) -> Result<OwnedMonitor> {
163            monitors
164                .into_iter()
165                .find(|m| m.focused())
166                .context("No focused monitor found")
167        }
168    }
169
170    /// There are two monitors in the fixture. One is marked active.
171    /// This test ensures that the focused monitor is returned by the function.
172    #[test]
173    fn test_get_focused_monitor() -> Result<()> {
174        let expected = helpers::focused_monitor(fixtures::monitors())?;
175        let returned = helpers::mock_service().get_focused_monitor()?;
176        assert_eq!(returned.name(), expected.name());
177        Ok(())
178    }
179
180    /// The first monitor has three workspaces, but only two are visible.
181    /// This test ensures that only the visible workspaces are returned
182    /// by the function.
183    #[test]
184    fn test_get_workspaces_for_monitor() -> Result<()> {
185        let target_monitor = &fixtures::monitors()[0];
186        let returned_workspaces =
187            helpers::mock_service().get_workspaces_for_monitor(target_monitor)?;
188        // All of the returned workspaces are visible
189        assert!(returned_workspaces.iter().all(|w| w.visible()));
190        // All of the returned workspaces match the argument monitor's name
191        assert!(returned_workspaces
192            .iter()
193            .all(|w| w.monitor_name() == target_monitor.name()));
194        // All of the expected workspaces are present
195        let expected_workspaces =
196            helpers::visible_for_monitor(fixtures::workspaces(), target_monitor);
197        assert_eq!(expected_workspaces, returned_workspaces);
198        Ok(())
199    }
200
201    /// Monitors each keep track of their active workspace.
202    /// Of the two monitors in the fixture, one is marked focused.
203    /// This test ensures that the function returns the focused monitor's
204    /// active workspace.
205    #[test]
206    fn test_get_current_workspace() -> Result<()> {
207        let expected = helpers::focused_monitor(fixtures::monitors())?;
208        let returned = helpers::mock_service().get_current_workspace()?;
209        assert_eq!(returned.id(), expected.active_workspace().id());
210        Ok(())
211    }
212
213    /// Hard to test this function's behavior. We can only really ensure that
214    /// the right underlying function call is made.
215    #[test]
216    fn test_switch_to_workspace() -> Result<()> {
217        let mut conn = MockHyprlandClient::new();
218        conn.expect_go_to_workspace().times(1).returning(|_| Ok(()));
219        helpers::mock_service_with(conn).switch_to_workspace(&fixtures::workspaces()[0])
220    }
221}