| 1 | //! Session selector dropdown widget |
| 2 | //! |
| 3 | //! Allows users to choose which desktop session to start. |
| 4 | |
| 5 | use crate::icons; |
| 6 | use crate::render::rounded_rectangle; |
| 7 | use crate::theme::Theme; |
| 8 | use anyhow::Result; |
| 9 | use cairo::Context; |
| 10 | use gardm_ipc::SessionInfo; |
| 11 | use pango::{FontDescription, Layout}; |
| 12 | |
| 13 | /// Session selector dropdown |
| 14 | pub struct SessionSelector { |
| 15 | sessions: Vec<SessionInfo>, |
| 16 | selected_index: usize, |
| 17 | expanded: bool, |
| 18 | hovered_index: Option<usize>, |
| 19 | |
| 20 | // Layout |
| 21 | x: f64, |
| 22 | y: f64, |
| 23 | width: f64, |
| 24 | item_height: f64, |
| 25 | } |
| 26 | |
| 27 | impl SessionSelector { |
| 28 | /// Create a new session selector with optional default session |
| 29 | pub fn new(sessions: Vec<SessionInfo>, x: f64, y: f64, width: f64, default_session: Option<&str>) -> Self { |
| 30 | // Find index of default session, or fall back to 0 |
| 31 | let selected_index = default_session |
| 32 | .and_then(|default| sessions.iter().position(|s| s.id == default)) |
| 33 | .unwrap_or(0); |
| 34 | |
| 35 | Self { |
| 36 | sessions, |
| 37 | selected_index, |
| 38 | expanded: false, |
| 39 | hovered_index: None, |
| 40 | x, |
| 41 | y, |
| 42 | width, |
| 43 | item_height: 40.0, |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// Get the currently selected session |
| 48 | pub fn selected(&self) -> Option<&SessionInfo> { |
| 49 | self.sessions.get(self.selected_index) |
| 50 | } |
| 51 | |
| 52 | /// Get the exec command for the selected session |
| 53 | pub fn selected_exec(&self) -> Option<&str> { |
| 54 | self.selected().map(|s| s.exec.as_str()) |
| 55 | } |
| 56 | |
| 57 | /// Get the session type for the selected session ("x11" or "wayland") |
| 58 | pub fn selected_type(&self) -> Option<&str> { |
| 59 | self.selected().map(|s| s.session_type.as_str()) |
| 60 | } |
| 61 | |
| 62 | /// Check if dropdown is expanded |
| 63 | pub fn is_expanded(&self) -> bool { |
| 64 | self.expanded |
| 65 | } |
| 66 | |
| 67 | /// Toggle dropdown expanded state |
| 68 | pub fn toggle(&mut self) { |
| 69 | self.expanded = !self.expanded; |
| 70 | if !self.expanded { |
| 71 | self.hovered_index = None; |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /// Close the dropdown |
| 76 | pub fn close(&mut self) { |
| 77 | self.expanded = false; |
| 78 | self.hovered_index = None; |
| 79 | } |
| 80 | |
| 81 | /// Render the session selector |
| 82 | pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> { |
| 83 | // Main button (always visible) |
| 84 | self.render_button(ctx, pango_ctx, theme)?; |
| 85 | |
| 86 | // Dropdown list (when expanded) |
| 87 | if self.expanded && !self.sessions.is_empty() { |
| 88 | self.render_dropdown(ctx, pango_ctx, theme)?; |
| 89 | } |
| 90 | |
| 91 | Ok(()) |
| 92 | } |
| 93 | |
| 94 | fn render_button(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> { |
| 95 | // Background |
| 96 | let bg = &theme.input_background; |
| 97 | ctx.set_source_rgba(bg.r, bg.g, bg.b, 0.9); |
| 98 | rounded_rectangle(ctx, self.x, self.y, self.width, self.item_height, 8.0); |
| 99 | ctx.fill()?; |
| 100 | |
| 101 | // Border |
| 102 | ctx.set_source_rgba(0.4, 0.4, 0.4, 0.8); |
| 103 | rounded_rectangle(ctx, self.x, self.y, self.width, self.item_height, 8.0); |
| 104 | ctx.set_line_width(1.0); |
| 105 | ctx.stroke()?; |
| 106 | |
| 107 | // Session name |
| 108 | let name = self |
| 109 | .selected() |
| 110 | .map(|s| s.name.as_str()) |
| 111 | .unwrap_or("No sessions"); |
| 112 | |
| 113 | let mut font = FontDescription::new(); |
| 114 | font.set_family(&theme.font_family); |
| 115 | font.set_size(13 * pango::SCALE); |
| 116 | |
| 117 | let layout = Layout::new(pango_ctx); |
| 118 | layout.set_font_description(Some(&font)); |
| 119 | layout.set_text(name); |
| 120 | |
| 121 | let tc = &theme.text_primary; |
| 122 | ctx.set_source_rgb(tc.r, tc.g, tc.b); |
| 123 | ctx.move_to(self.x + 12.0, self.y + (self.item_height - 16.0) / 2.0); |
| 124 | pangocairo::functions::show_layout(ctx, &layout); |
| 125 | |
| 126 | // Chevron icon |
| 127 | let chevron_size = 16.0; |
| 128 | let chevron_x = self.x + self.width - chevron_size - 12.0; |
| 129 | let chevron_y = self.y + (self.item_height - chevron_size) / 2.0; |
| 130 | let sc = &theme.text_secondary; |
| 131 | ctx.set_source_rgba(sc.r, sc.g, sc.b, sc.a); |
| 132 | icons::draw_chevron_down(ctx, chevron_x, chevron_y, chevron_size); |
| 133 | |
| 134 | Ok(()) |
| 135 | } |
| 136 | |
| 137 | fn render_dropdown(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> { |
| 138 | let dropdown_height = self.sessions.len() as f64 * self.item_height; |
| 139 | let dropdown_y = self.y - dropdown_height - 4.0; // Above the button |
| 140 | |
| 141 | // Dropdown background |
| 142 | let bg = &theme.panel_background; |
| 143 | ctx.set_source_rgba(bg.r * 0.8, bg.g * 0.8, bg.b * 0.8, 0.95); |
| 144 | rounded_rectangle(ctx, self.x, dropdown_y, self.width, dropdown_height, 8.0); |
| 145 | ctx.fill()?; |
| 146 | |
| 147 | // Border |
| 148 | ctx.set_source_rgba(0.3, 0.3, 0.3, 0.8); |
| 149 | rounded_rectangle(ctx, self.x, dropdown_y, self.width, dropdown_height, 8.0); |
| 150 | ctx.set_line_width(1.0); |
| 151 | ctx.stroke()?; |
| 152 | |
| 153 | // Items |
| 154 | let mut font = FontDescription::new(); |
| 155 | font.set_family(&theme.font_family); |
| 156 | font.set_size(13 * pango::SCALE); |
| 157 | |
| 158 | for (i, session) in self.sessions.iter().enumerate() { |
| 159 | let item_y = dropdown_y + i as f64 * self.item_height; |
| 160 | let is_selected = i == self.selected_index; |
| 161 | let is_hovered = self.hovered_index == Some(i); |
| 162 | |
| 163 | // Item background on hover |
| 164 | if is_hovered { |
| 165 | let ac = &theme.accent; |
| 166 | ctx.set_source_rgba(ac.r, ac.g, ac.b, 0.5); |
| 167 | if i == 0 { |
| 168 | // First item - round top corners |
| 169 | rounded_rectangle(ctx, self.x + 2.0, item_y + 2.0, self.width - 4.0, self.item_height - 2.0, 6.0); |
| 170 | } else if i == self.sessions.len() - 1 { |
| 171 | // Last item - round bottom corners |
| 172 | rounded_rectangle(ctx, self.x + 2.0, item_y, self.width - 4.0, self.item_height - 2.0, 6.0); |
| 173 | } else { |
| 174 | ctx.rectangle(self.x + 2.0, item_y, self.width - 4.0, self.item_height); |
| 175 | } |
| 176 | ctx.fill()?; |
| 177 | } |
| 178 | |
| 179 | // Checkmark for selected item |
| 180 | if is_selected { |
| 181 | ctx.set_source_rgba(0.4, 0.8, 0.4, 1.0); |
| 182 | icons::draw_checkmark(ctx, self.x + 8.0, item_y + 8.0, 24.0); |
| 183 | } |
| 184 | |
| 185 | // Session name |
| 186 | let layout = Layout::new(pango_ctx); |
| 187 | layout.set_font_description(Some(&font)); |
| 188 | layout.set_text(&session.name); |
| 189 | |
| 190 | let tc = &theme.text_primary; |
| 191 | if is_selected { |
| 192 | ctx.set_source_rgb(tc.r, tc.g, tc.b); |
| 193 | } else { |
| 194 | ctx.set_source_rgba(tc.r, tc.g, tc.b, 0.9); |
| 195 | } |
| 196 | ctx.move_to(self.x + 36.0, item_y + (self.item_height - 16.0) / 2.0); |
| 197 | pangocairo::functions::show_layout(ctx, &layout); |
| 198 | } |
| 199 | |
| 200 | Ok(()) |
| 201 | } |
| 202 | |
| 203 | /// Update hover state based on mouse position |
| 204 | pub fn update_hover(&mut self, mouse_x: f64, mouse_y: f64) -> bool { |
| 205 | if !self.expanded { |
| 206 | return false; |
| 207 | } |
| 208 | |
| 209 | let dropdown_height = self.sessions.len() as f64 * self.item_height; |
| 210 | let dropdown_y = self.y - dropdown_height - 4.0; |
| 211 | |
| 212 | let old_hover = self.hovered_index; |
| 213 | |
| 214 | // Check if mouse is in dropdown area |
| 215 | if mouse_x >= self.x |
| 216 | && mouse_x <= self.x + self.width |
| 217 | && mouse_y >= dropdown_y |
| 218 | && mouse_y <= dropdown_y + dropdown_height |
| 219 | { |
| 220 | let relative_y = mouse_y - dropdown_y; |
| 221 | self.hovered_index = Some((relative_y / self.item_height) as usize); |
| 222 | } else { |
| 223 | self.hovered_index = None; |
| 224 | } |
| 225 | |
| 226 | self.hovered_index != old_hover |
| 227 | } |
| 228 | |
| 229 | /// Check if click is on the main button |
| 230 | pub fn button_contains(&self, x: f64, y: f64) -> bool { |
| 231 | x >= self.x && x <= self.x + self.width && y >= self.y && y <= self.y + self.item_height |
| 232 | } |
| 233 | |
| 234 | /// Check if click is in the dropdown and handle selection |
| 235 | /// Returns true if a selection was made |
| 236 | pub fn handle_dropdown_click(&mut self, click_x: f64, click_y: f64) -> bool { |
| 237 | if !self.expanded { |
| 238 | return false; |
| 239 | } |
| 240 | |
| 241 | let dropdown_height = self.sessions.len() as f64 * self.item_height; |
| 242 | let dropdown_y = self.y - dropdown_height - 4.0; |
| 243 | |
| 244 | if click_x >= self.x |
| 245 | && click_x <= self.x + self.width |
| 246 | && click_y >= dropdown_y |
| 247 | && click_y <= dropdown_y + dropdown_height |
| 248 | { |
| 249 | let relative_y = click_y - dropdown_y; |
| 250 | let index = (relative_y / self.item_height) as usize; |
| 251 | if index < self.sessions.len() { |
| 252 | self.selected_index = index; |
| 253 | self.close(); |
| 254 | return true; |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | false |
| 259 | } |
| 260 | |
| 261 | /// Check if a point is within the selector bounds (button or dropdown) |
| 262 | pub fn contains(&self, x: f64, y: f64) -> bool { |
| 263 | // Check main button |
| 264 | if self.button_contains(x, y) { |
| 265 | return true; |
| 266 | } |
| 267 | |
| 268 | // Check dropdown if expanded |
| 269 | if self.expanded { |
| 270 | let dropdown_height = self.sessions.len() as f64 * self.item_height; |
| 271 | let dropdown_y = self.y - dropdown_height - 4.0; |
| 272 | |
| 273 | if x >= self.x |
| 274 | && x <= self.x + self.width |
| 275 | && y >= dropdown_y |
| 276 | && y <= dropdown_y + dropdown_height |
| 277 | { |
| 278 | return true; |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | false |
| 283 | } |
| 284 | } |
| 285 |