Skip to main content
Glama
button.rs11 kB
//! Button detector for clickable button UI elements. use terminal_mcp_core::{Bounds, Element}; use terminal_mcp_emulator::Grid; use crate::detection::{Confidence, DetectedElement, DetectionContext, ElementDetector}; /// Button pattern with open/close delimiters. #[derive(Debug, Clone)] struct ButtonPattern { open: &'static str, close: &'static str, } /// Button detector for clickable buttons in bracket patterns. pub struct ButtonDetector { patterns: Vec<ButtonPattern>, max_label_length: usize, } impl ButtonDetector { /// Create a new button detector. pub fn new() -> Self { Self { patterns: vec![ ButtonPattern { open: "[ ", close: " ]", }, ButtonPattern { open: "[", close: "]", }, ButtonPattern { open: "< ", close: " >", }, ButtonPattern { open: "<", close: ">", }, ButtonPattern { open: "( ", close: " )", }, ButtonPattern { open: "(", close: ")", }, ButtonPattern { open: "「", close: "」", }, ], max_label_length: 30, } } /// Extract text from a specific row. fn extract_row_text(&self, grid: &Grid, row: u16) -> String { let dims = grid.dimensions(); let mut text = String::new(); for col in 0..dims.cols { if let Some(cell) = grid.cell(row, col) { text.push(cell.character); } } text } /// Check if a button at the given position overlaps with existing buttons. fn overlaps_existing(&self, start: usize, end: usize, existing: &[DetectedElement]) -> bool { existing.iter().any(|button| { let button_start = button.bounds.col as usize; let button_end = (button.bounds.col + button.bounds.width) as usize; !(end <= button_start || start >= button_end) }) } /// Safely get a substring using byte indices, returning None if indices are invalid. fn safe_slice(s: &str, start: usize, end: usize) -> Option<&str> { if start <= end && end <= s.len() && s.is_char_boundary(start) && s.is_char_boundary(end) { Some(&s[start..end]) } else { None } } /// Detect buttons in a specific row. fn detect_buttons_in_row(&self, grid: &Grid, row: u16) -> Vec<DetectedElement> { let mut buttons = Vec::new(); let row_text = self.extract_row_text(grid, row); for pattern in &self.patterns { let mut search_start = 0; while let Some(start) = Self::safe_slice(&row_text, search_start, row_text.len()) .and_then(|s| s.find(pattern.open)) { let abs_start = search_start + start; let after_open = abs_start + pattern.open.len(); let Some(end) = Self::safe_slice(&row_text, after_open, row_text.len()) .and_then(|s| s.find(pattern.close)) else { break; }; let label_start = after_open; let label_end = after_open + end; let Some(label_str) = Self::safe_slice(&row_text, label_start, label_end) else { search_start = after_open; continue; }; let label = label_str.trim().to_string(); // Validate label: must be non-empty, reasonable length, and not contain delimiters let contains_delimiters = label.contains('[') || label.contains(']') || label.contains('<') || label.contains('>') || label.contains('(') || label.contains(')') || label.contains('「') || label.contains('」'); if !label.is_empty() && label.len() <= self.max_label_length && !contains_delimiters { let button_width = (label_end + pattern.close.len() - abs_start) as u16; let button_end = abs_start + button_width as usize; // Skip if this overlaps with an already-detected button if !self.overlaps_existing(abs_start, button_end, &buttons) { let ref_id = format!("button_{row}_{abs_start}"); buttons.push(DetectedElement { element: Element::Button { ref_id, bounds: Bounds::new(row, abs_start as u16, button_width, 1), label, }, bounds: Bounds::new(row, abs_start as u16, button_width, 1), confidence: Confidence::High, }); } } search_start = abs_start + pattern.open.len() + end + pattern.close.len(); } } buttons } } impl Default for ButtonDetector { fn default() -> Self { Self::new() } } impl ElementDetector for ButtonDetector { fn name(&self) -> &'static str { "button" } fn priority(&self) -> u32 { 60 } fn detect(&self, grid: &Grid, context: &DetectionContext) -> Vec<DetectedElement> { let mut results = Vec::new(); let dims = grid.dimensions(); for row in 0..dims.rows { let buttons = self.detect_buttons_in_row(grid, row); for button in buttons { // Skip if region already claimed if !context.is_region_claimed(&button.bounds) { results.push(button); } } } results } } #[cfg(test)] mod tests { use super::*; use terminal_mcp_core::{Dimensions, Position}; use terminal_mcp_emulator::{Grid, Parser}; fn create_grid_with_text(rows: u16, cols: u16, text: &str) -> Grid { let grid = Grid::new(Dimensions::new(rows, cols)); let mut parser = Parser::new(grid); parser.process(text.as_bytes()); parser.into_grid() } #[test] fn test_button_detector_bracket_pattern() { let text = "[ OK ] [ Cancel ]\r\n"; let grid = create_grid_with_text(5, 40, text); let detector = ButtonDetector::new(); let context = DetectionContext::new(Position::new(0, 0)); let detected = detector.detect(&grid, &context); assert_eq!(detected.len(), 2); if let Element::Button { label, .. } = &detected[0].element { assert_eq!(label, "OK"); } else { panic!("Expected Button element"); } if let Element::Button { label, .. } = &detected[1].element { assert_eq!(label, "Cancel"); } } #[test] fn test_button_detector_angle_pattern() { let text = "< Submit > < Reset >\r\n"; let grid = create_grid_with_text(5, 40, text); let detector = ButtonDetector::new(); let context = DetectionContext::new(Position::new(0, 0)); let detected = detector.detect(&grid, &context); assert_eq!(detected.len(), 2); if let Element::Button { label, .. } = &detected[0].element { assert_eq!(label, "Submit"); } if let Element::Button { label, .. } = &detected[1].element { assert_eq!(label, "Reset"); } } #[test] fn test_button_detector_paren_pattern() { let text = "( Yes ) ( No )\r\n"; let grid = create_grid_with_text(5, 40, text); let detector = ButtonDetector::new(); let context = DetectionContext::new(Position::new(0, 0)); let detected = detector.detect(&grid, &context); assert_eq!(detected.len(), 2); if let Element::Button { label, .. } = &detected[0].element { assert_eq!(label, "Yes"); } if let Element::Button { label, .. } = &detected[1].element { assert_eq!(label, "No"); } } #[test] fn test_button_detector_tight_bracket() { let text = "[OK] [Cancel]\r\n"; let grid = create_grid_with_text(5, 40, text); let detector = ButtonDetector::new(); let context = DetectionContext::new(Position::new(0, 0)); let detected = detector.detect(&grid, &context); assert_eq!(detected.len(), 2); if let Element::Button { label, .. } = &detected[0].element { assert_eq!(label, "OK"); } if let Element::Button { label, .. } = &detected[1].element { assert_eq!(label, "Cancel"); } } #[test] fn test_button_detector_priority() { let detector = ButtonDetector::new(); assert_eq!(detector.priority(), 60); assert_eq!(detector.name(), "button"); } #[test] fn test_button_detector_mixed_patterns() { let text = "[OK] < Cancel > (Help)\r\n"; let grid = create_grid_with_text(5, 40, text); let detector = ButtonDetector::new(); let context = DetectionContext::new(Position::new(0, 0)); let detected = detector.detect(&grid, &context); assert_eq!(detected.len(), 3); assert!(matches!(&detected[0].element, Element::Button { label, .. } if label == "OK")); assert!(matches!(&detected[1].element, Element::Button { label, .. } if label == "Cancel")); assert!(matches!(&detected[2].element, Element::Button { label, .. } if label == "Help")); } #[test] fn test_button_detector_empty_label_rejected() { let text = "[ ] [ ]\r\n"; let grid = create_grid_with_text(5, 40, text); let detector = ButtonDetector::new(); let context = DetectionContext::new(Position::new(0, 0)); let detected = detector.detect(&grid, &context); // Empty labels should not be detected as buttons assert_eq!(detected.len(), 0); } #[test] fn test_button_detector_long_label_accepted() { // Test that reasonable button labels work let text = "[Continue]\r\n"; let grid = create_grid_with_text(5, 40, text); let detector = ButtonDetector::new(); let context = DetectionContext::new(Position::new(0, 0)); let detected = detector.detect(&grid, &context); assert_eq!(detected.len(), 1); if let Element::Button { label, .. } = &detected[0].element { assert_eq!(label, "Continue"); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aybelatchane/mcp-server-terminal'

If you have feedback or need assistance with the MCP directory API, please join our Discord server