@@ -1,8 +1,8 @@ |
| 1 | 1 | use crate::config::{Action, Config, KeybindSet, LuaRuntime, Modifiers as ConfigModifiers, TerminalCommand, expand_tilde}; |
| 2 | | -use crate::input::{Clipboard, KeyboardHandler, MouseButton, MouseEvent, MouseHandler, Selection, SelectionMode}; |
| 2 | +use crate::input::{Clipboard, KeyboardHandler, MouseButton, MouseEvent, MouseHandler, SearchState, Selection, SelectionMode}; |
| 3 | 3 | use crate::ipc::IpcServer; |
| 4 | 4 | use crate::pty::{PtySize, ReceivedSignal, SignalHandler}; |
| 5 | | -use crate::render::{PaneRenderInfo, Renderer, SelectionBounds}; |
| 5 | +use crate::render::{PaneRenderInfo, Renderer, SearchOverlay, SelectionBounds}; |
| 6 | 6 | use crate::ui::{Direction, PaneId, TabManager}; |
| 7 | 7 | use anyhow::Result; |
| 8 | 8 | use garterm_ipc::{Command, Response}; |
@@ -47,6 +47,8 @@ pub struct App { |
| 47 | 47 | fullscreen: bool, |
| 48 | 48 | /// Original font size (for reset) |
| 49 | 49 | original_font_size: f32, |
| 50 | + /// Search state for the focused pane |
| 51 | + search: SearchState, |
| 50 | 52 | } |
| 51 | 53 | |
| 52 | 54 | impl App { |
@@ -196,6 +198,7 @@ impl App { |
| 196 | 198 | net_wm_state_fullscreen, |
| 197 | 199 | fullscreen: false, |
| 198 | 200 | original_font_size: config.font.size, |
| 201 | + search: SearchState::new(), |
| 199 | 202 | }) |
| 200 | 203 | } |
| 201 | 204 | |
@@ -319,12 +322,25 @@ impl App { |
| 319 | 322 | focused: pane.focused, |
| 320 | 323 | // Only show selection on focused pane |
| 321 | 324 | selection: if pane.focused { selection_bounds } else { None }, |
| 325 | + search: None, // Search rendering handled separately |
| 322 | 326 | }).collect() |
| 323 | 327 | }) |
| 324 | 328 | .unwrap_or_default(); |
| 325 | 329 | |
| 326 | 330 | if !pane_infos.is_empty() { |
| 327 | | - self.renderer.render_scene(&tab_bar_data, &pane_infos)?; |
| 331 | + // Build search overlay if search is active or has matches |
| 332 | + let match_count = self.search.match_count_text(); |
| 333 | + let search_overlay = if self.search.active || !self.search.matches.is_empty() { |
| 334 | + Some(SearchOverlay { |
| 335 | + query: &self.search.query, |
| 336 | + match_count: &match_count, |
| 337 | + active: self.search.active, |
| 338 | + case_insensitive: self.search.case_insensitive, |
| 339 | + }) |
| 340 | + } else { |
| 341 | + None |
| 342 | + }; |
| 343 | + self.renderer.render_scene_with_search(&tab_bar_data, &pane_infos, search_overlay.as_ref())?; |
| 328 | 344 | } |
| 329 | 345 | self.window.connection().flush()?; |
| 330 | 346 | } |
@@ -497,10 +513,20 @@ impl App { |
| 497 | 513 | Ok(true) |
| 498 | 514 | } |
| 499 | 515 | |
| 500 | | - // Search (TODO: implement search) |
| 501 | | - Action::SearchForward | Action::SearchBackward => { |
| 502 | | - // TODO: Implement search |
| 503 | | - Ok(false) |
| 516 | + // Search |
| 517 | + Action::SearchForward => { |
| 518 | + info!("Starting forward search"); |
| 519 | + self.search.start(); |
| 520 | + self.tabs.mark_all_dirty(); |
| 521 | + Ok(true) |
| 522 | + } |
| 523 | + Action::SearchBackward => { |
| 524 | + info!("Starting backward search"); |
| 525 | + self.search.start(); |
| 526 | + // For backward search, we could track direction |
| 527 | + // but for now just start search mode |
| 528 | + self.tabs.mark_all_dirty(); |
| 529 | + Ok(true) |
| 504 | 530 | } |
| 505 | 531 | |
| 506 | 532 | // Misc |
@@ -1115,12 +1141,25 @@ impl App { |
| 1115 | 1141 | height: pane.height, |
| 1116 | 1142 | focused: pane.focused, |
| 1117 | 1143 | selection: if pane.focused { selection_bounds } else { None }, |
| 1144 | + search: None, // Search rendering handled separately |
| 1118 | 1145 | }).collect() |
| 1119 | 1146 | }) |
| 1120 | 1147 | .unwrap_or_default(); |
| 1121 | 1148 | |
| 1122 | 1149 | if !pane_infos.is_empty() { |
| 1123 | | - self.renderer.render_scene(&tab_bar_data, &pane_infos)?; |
| 1150 | + // Build search overlay if search is active or has matches |
| 1151 | + let match_count = self.search.match_count_text(); |
| 1152 | + let search_overlay = if self.search.active || !self.search.matches.is_empty() { |
| 1153 | + Some(SearchOverlay { |
| 1154 | + query: &self.search.query, |
| 1155 | + match_count: &match_count, |
| 1156 | + active: self.search.active, |
| 1157 | + case_insensitive: self.search.case_insensitive, |
| 1158 | + }) |
| 1159 | + } else { |
| 1160 | + None |
| 1161 | + }; |
| 1162 | + self.renderer.render_scene_with_search(&tab_bar_data, &pane_infos, search_overlay.as_ref())?; |
| 1124 | 1163 | } |
| 1125 | 1164 | } |
| 1126 | 1165 | } |
@@ -1200,6 +1239,11 @@ impl App { |
| 1200 | 1239 | tracing::debug!("Key press: {:?}, modifiers: ctrl={}, shift={}, alt={}", |
| 1201 | 1240 | key, modifiers.ctrl, modifiers.shift, modifiers.alt); |
| 1202 | 1241 | |
| 1242 | + // Handle search mode input |
| 1243 | + if self.search.active { |
| 1244 | + return self.handle_search_key(key, &modifiers); |
| 1245 | + } |
| 1246 | + |
| 1203 | 1247 | // Convert to config modifiers and key string for lookup |
| 1204 | 1248 | let config_mods = Self::modifiers_to_config(&modifiers); |
| 1205 | 1249 | let key_str = Self::key_to_string(&key); |
@@ -1245,6 +1289,109 @@ impl App { |
| 1245 | 1289 | Ok(()) |
| 1246 | 1290 | } |
| 1247 | 1291 | |
| 1292 | + /// Handle key events in search mode |
| 1293 | + fn handle_search_key(&mut self, key: gartk_core::Key, modifiers: &gartk_core::Modifiers) -> Result<()> { |
| 1294 | + use gartk_core::Key; |
| 1295 | + |
| 1296 | + match key { |
| 1297 | + // Escape or Ctrl+C cancels search |
| 1298 | + Key::Escape => { |
| 1299 | + self.search.cancel(); |
| 1300 | + self.tabs.mark_all_dirty(); |
| 1301 | + } |
| 1302 | + // Enter confirms search (exits input mode but keeps highlights) |
| 1303 | + Key::Return => { |
| 1304 | + self.search.confirm(); |
| 1305 | + self.tabs.mark_all_dirty(); |
| 1306 | + } |
| 1307 | + // Backspace removes last character |
| 1308 | + Key::Backspace => { |
| 1309 | + self.search.pop_char(); |
| 1310 | + self.update_search_matches(); |
| 1311 | + self.tabs.mark_all_dirty(); |
| 1312 | + } |
| 1313 | + // n/N for next/previous match (when not typing) |
| 1314 | + Key::Char('n') if modifiers.ctrl => { |
| 1315 | + self.search.next_match(); |
| 1316 | + self.scroll_to_current_match(); |
| 1317 | + self.tabs.mark_all_dirty(); |
| 1318 | + } |
| 1319 | + Key::Char('n') if modifiers.shift => { |
| 1320 | + self.search.prev_match(); |
| 1321 | + self.scroll_to_current_match(); |
| 1322 | + self.tabs.mark_all_dirty(); |
| 1323 | + } |
| 1324 | + Key::Char('n') if !modifiers.ctrl && !modifiers.shift && !modifiers.alt => { |
| 1325 | + if self.search.query.is_empty() { |
| 1326 | + self.search.push_char('n'); |
| 1327 | + self.update_search_matches(); |
| 1328 | + } else { |
| 1329 | + self.search.next_match(); |
| 1330 | + self.scroll_to_current_match(); |
| 1331 | + } |
| 1332 | + self.tabs.mark_all_dirty(); |
| 1333 | + } |
| 1334 | + Key::Char('N') => { |
| 1335 | + self.search.prev_match(); |
| 1336 | + self.scroll_to_current_match(); |
| 1337 | + self.tabs.mark_all_dirty(); |
| 1338 | + } |
| 1339 | + // Ctrl+I toggles case sensitivity |
| 1340 | + Key::Char('i') if modifiers.ctrl => { |
| 1341 | + self.search.toggle_case_sensitive(); |
| 1342 | + self.update_search_matches(); |
| 1343 | + self.tabs.mark_all_dirty(); |
| 1344 | + } |
| 1345 | + // Regular characters add to query |
| 1346 | + Key::Char(c) => { |
| 1347 | + self.search.push_char(c); |
| 1348 | + self.update_search_matches(); |
| 1349 | + self.tabs.mark_all_dirty(); |
| 1350 | + } |
| 1351 | + _ => {} |
| 1352 | + } |
| 1353 | + |
| 1354 | + Ok(()) |
| 1355 | + } |
| 1356 | + |
| 1357 | + /// Update search matches based on current query |
| 1358 | + fn update_search_matches(&mut self) { |
| 1359 | + if let Some(pane) = self.tabs.focused_pane() { |
| 1360 | + let matches = pane.terminal.grid().search( |
| 1361 | + &self.search.query, |
| 1362 | + self.search.case_insensitive, |
| 1363 | + ); |
| 1364 | + self.search.set_matches(matches); |
| 1365 | + |
| 1366 | + // Auto-scroll to first match |
| 1367 | + if !self.search.matches.is_empty() { |
| 1368 | + self.scroll_to_current_match(); |
| 1369 | + } |
| 1370 | + } |
| 1371 | + } |
| 1372 | + |
| 1373 | + /// Scroll viewport to show the current match |
| 1374 | + fn scroll_to_current_match(&mut self) { |
| 1375 | + if let Some(m) = self.search.current() { |
| 1376 | + if let Some(pane) = self.tabs.focused_pane_mut() { |
| 1377 | + let grid = pane.terminal.grid(); |
| 1378 | + let scrollback_len = grid.scrollback_len(); |
| 1379 | + |
| 1380 | + // Calculate the scroll offset needed to show this match |
| 1381 | + // Match row is absolute (0 = start of scrollback) |
| 1382 | + if m.row < scrollback_len { |
| 1383 | + // Match is in scrollback |
| 1384 | + let offset = scrollback_len - m.row; |
| 1385 | + pane.terminal.grid_mut().set_scroll_offset(offset); |
| 1386 | + } else { |
| 1387 | + // Match is in active display - scroll to bottom |
| 1388 | + pane.terminal.reset_viewport(); |
| 1389 | + } |
| 1390 | + pane.mark_dirty(); |
| 1391 | + } |
| 1392 | + } |
| 1393 | + } |
| 1394 | + |
| 1248 | 1395 | fn handle_button_press(&mut self, event: xproto::ButtonPressEvent) -> Result<()> { |
| 1249 | 1396 | let (cell_w, cell_h) = self.renderer.cell_size(); |
| 1250 | 1397 | |