main.rs
1 use iced::widget::{ 2 button, checkbox, column, container, mouse_area, pick_list, row, scrollable, text, text_input, 3 }; 4 use iced::{ 5 executor, time, Application, Background, Border, Color, Command, Element, Font, Length, Shadow, 6 Subscription, Theme, Vector, 7 }; 8 9 use std::collections::{HashMap, VecDeque}; 10 use std::f32::consts::TAU; 11 use std::path::PathBuf; 12 use std::sync::atomic::{AtomicBool, Ordering}; 13 use std::sync::{mpsc, Arc}; 14 use std::time::{Duration, SystemTime}; 15 16 use decode::FrameSummary; 17 use dr_nose::{agg::Aggregator, capture_macos, db, decode, now_ns}; 18 19 const MAX_RAW_STORE: usize = 5000; 20 const MAX_TIMELINE: usize = 2000; 21 const MAX_APS: usize = 200; 22 const MAX_SAVED_LIST: usize = 400; 23 24 fn main() -> iced::Result { 25 let mut settings = iced::Settings::default(); 26 settings.fonts = vec![include_bytes!("../assets/fonts/ShareTechMono-Regular.ttf") 27 .as_slice() 28 .into()]; 29 settings.default_font = SHARE_TECH_MONO; 30 WiFiLensApp::run(settings) 31 } 32 33 #[derive(Debug, Clone)] 34 enum Message { 35 Tick, 36 StopCapture, 37 38 RefreshVarTmp, 39 UseVarTmpFile(usize), 40 UseNewestVarTmp, 41 42 PcapPathChanged(String), 43 DbPathChanged(String), 44 45 OpenWirelessDiagnostics, 46 LoadFromPath, 47 48 ToggleMgmtOnly(bool), 49 ToggleTail(bool), 50 ToggleShowSaved(bool), 51 52 FilterBssidChanged(String), 53 FilterSsidChanged(String), 54 FilterChannelChanged(String), 55 FilterFrameTypeChanged(FrameTypeFilter), 56 ExportPathChanged(String), 57 58 RefreshSaved, 59 ExportSavedPcap, 60 ExportSavedCsv, 61 ExportSavedJson, 62 63 SaveFrame(usize), 64 InspectFrame(usize), 65 InspectSaved(i64), 66 67 HoverPanel(Option<PanelId>), 68 } 69 70 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 71 enum PanelId { 72 TopBar, 73 Inputs, 74 Status, 75 Telemetry, 76 VarTmp, 77 AccessPoints, 78 Timeline, 79 } 80 81 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 82 enum FrameTypeFilter { 83 All, 84 Mgmt, 85 Ctrl, 86 Data, 87 Other, 88 } 89 90 impl FrameTypeFilter { 91 const ALL: [FrameTypeFilter; 5] = [ 92 FrameTypeFilter::All, 93 FrameTypeFilter::Mgmt, 94 FrameTypeFilter::Ctrl, 95 FrameTypeFilter::Data, 96 FrameTypeFilter::Other, 97 ]; 98 } 99 100 impl std::fmt::Display for FrameTypeFilter { 101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 102 let label = match self { 103 FrameTypeFilter::All => "all", 104 FrameTypeFilter::Mgmt => "mgmt", 105 FrameTypeFilter::Ctrl => "ctrl", 106 FrameTypeFilter::Data => "data", 107 FrameTypeFilter::Other => "other", 108 }; 109 f.write_str(label) 110 } 111 } 112 113 #[derive(Debug, Clone)] 114 struct FrameDetail { 115 title: String, 116 lines: Vec<String>, 117 } 118 119 #[derive(Debug, Clone, Copy)] 120 enum ExportFormat { 121 Pcap, 122 Csv, 123 Json, 124 } 125 126 #[derive(Debug, Clone)] 127 enum CaptureEvent { 128 Started { path: String }, 129 Frame { summary: FrameSummary, raw: Vec<u8> }, 130 Done, 131 Error(String), 132 } 133 134 struct CaptureHandle { 135 rx: mpsc::Receiver<CaptureEvent>, 136 stop: Arc<AtomicBool>, 137 } 138 139 struct CaptureFileRow { 140 path: PathBuf, 141 modified: SystemTime, 142 size: u64, 143 } 144 145 struct WiFiLensApp { 146 status: String, 147 148 // Inputs 149 pcap_path_input: String, 150 db_path_input: String, 151 152 // macOS /var/tmp browsing 153 var_tmp_files: Vec<CaptureFileRow>, 154 selected_var_tmp: Option<usize>, 155 156 // Capture state 157 capture: Option<CaptureHandle>, 158 is_running: bool, 159 160 // Aggregation 161 agg: Aggregator, 162 mgmt_only: bool, 163 tail_capture: bool, 164 show_saved: bool, 165 166 filter_bssid: String, 167 filter_ssid: String, 168 filter_channel: String, 169 filter_frame_type: FrameTypeFilter, 170 export_path_input: String, 171 172 // Indexed decoded frames for saving 173 frames_by_idx: HashMap<usize, FrameSummary>, 174 raw_by_idx: HashMap<usize, Vec<u8>>, 175 raw_order: VecDeque<usize>, 176 177 // DB session 178 session_id: Option<i64>, 179 db_path: PathBuf, 180 181 saved_count: usize, 182 saved_packets: Vec<db::SavedPacketRow>, 183 glow_phase: f32, 184 hovered_panel: Option<PanelId>, 185 detail: Option<FrameDetail>, 186 } 187 188 impl WiFiLensApp { 189 fn glow_pulse(&self) -> f32 { 190 0.5 + 0.5 * self.glow_phase.sin() 191 } 192 193 fn refresh_var_tmp(&mut self) { 194 self.var_tmp_files.clear(); 195 self.selected_var_tmp = None; 196 197 if !cfg!(target_os = "macos") { 198 self.status = "macOS /var/tmp list is only available on macOS".to_string(); 199 return; 200 } 201 202 match capture_macos::list_var_tmp_captures(200) { 203 Ok(list) => { 204 self.var_tmp_files = list 205 .into_iter() 206 .map(|c| CaptureFileRow { 207 path: c.path, 208 modified: c.modified, 209 size: c.size, 210 }) 211 .collect(); 212 self.status = format!( 213 "Found {} capture files in /var/tmp", 214 self.var_tmp_files.len() 215 ); 216 } 217 Err(e) => { 218 self.status = format!("Failed to list /var/tmp: {e}"); 219 } 220 } 221 } 222 223 fn stop_capture(&mut self) { 224 if let Some(h) = &self.capture { 225 h.stop.store(true, Ordering::Relaxed); 226 } 227 self.capture = None; 228 self.is_running = false; 229 } 230 231 fn reset_session_and_state(&mut self) { 232 self.stop_capture(); 233 self.agg = Aggregator::new(MAX_TIMELINE, MAX_APS); 234 self.frames_by_idx.clear(); 235 self.raw_by_idx.clear(); 236 self.raw_order.clear(); 237 self.session_id = None; 238 self.saved_count = 0; 239 self.saved_packets.clear(); 240 self.detail = None; 241 } 242 243 fn begin_session(&mut self, source: &str) { 244 let platform = if cfg!(target_os = "macos") { 245 "macos" 246 } else if cfg!(target_os = "linux") { 247 "linux" 248 } else { 249 "other" 250 }; 251 252 match db::open_db(&self.db_path) { 253 Ok(conn) => match db::create_session(&conn, platform, source, now_ns()) { 254 Ok(session_id) => { 255 self.session_id = Some(session_id); 256 } 257 Err(e) => { 258 self.status = format!("DB: failed to create session: {e}"); 259 self.session_id = None; 260 } 261 }, 262 Err(e) => { 263 self.status = format!("DB: failed to open {}: {e}", self.db_path.display()); 264 self.session_id = None; 265 } 266 } 267 } 268 269 fn maybe_end_session(&mut self) { 270 let Some(session_id) = self.session_id else { 271 return; 272 }; 273 match db::open_db(&self.db_path) { 274 Ok(conn) => { 275 let _ = db::end_session(&conn, session_id, now_ns()); 276 } 277 Err(_) => {} 278 } 279 } 280 281 fn start_capture_from_path(&mut self, path: PathBuf) { 282 self.reset_session_and_state(); 283 284 let source = path.display().to_string(); 285 self.begin_session(&source); 286 287 let (tx, rx) = mpsc::channel::<CaptureEvent>(); 288 let stop = Arc::new(AtomicBool::new(false)); 289 let stop_thread = stop.clone(); 290 291 // Spawn decoder thread. 292 let tail_capture = self.tail_capture; 293 std::thread::spawn(move || { 294 if let Err(e) = capture_decode_file(path, tx.clone(), stop_thread, tail_capture) { 295 let _ = tx.send(CaptureEvent::Error(e)); 296 } 297 }); 298 299 self.capture = Some(CaptureHandle { rx, stop }); 300 self.is_running = true; 301 self.status = if self.tail_capture { 302 "Capture started (tailing)".to_string() 303 } else { 304 "Capture started".to_string() 305 }; 306 } 307 308 fn ingest_frame(&mut self, summary: FrameSummary, raw: Vec<u8>) { 309 // Maintain limited stores for save-by-index. 310 let idx = summary.idx; 311 self.frames_by_idx.insert(idx, summary.clone()); 312 self.raw_by_idx.insert(idx, raw); 313 self.raw_order.push_back(idx); 314 while self.raw_order.len() > MAX_RAW_STORE { 315 if let Some(old) = self.raw_order.pop_front() { 316 self.raw_by_idx.remove(&old); 317 self.frames_by_idx.remove(&old); 318 } 319 } 320 321 // Aggregation 322 self.agg.ingest(summary); 323 324 // Keep AP count bounded 325 // (Aggregator currently holds all APs; bounding that would need changes. 326 // For MVP, we bound only the displayed list.) 327 } 328 329 fn drain_capture_events(&mut self) { 330 loop { 331 let next = { 332 let Some(h) = &self.capture else { 333 return; 334 }; 335 h.rx.try_recv() 336 }; 337 match next { 338 Ok(ev) => self.handle_capture_event(ev), 339 Err(mpsc::TryRecvError::Empty) => break, 340 Err(mpsc::TryRecvError::Disconnected) => { 341 self.is_running = false; 342 break; 343 } 344 } 345 } 346 } 347 348 fn handle_capture_event(&mut self, ev: CaptureEvent) { 349 match ev { 350 CaptureEvent::Started { path } => { 351 self.status = format!("Reading: {path}"); 352 } 353 CaptureEvent::Frame { summary, raw } => { 354 if self.mgmt_only { 355 if summary.dot11_type != Some(0) { 356 return; 357 } 358 } 359 self.ingest_frame(summary, raw); 360 } 361 CaptureEvent::Done => { 362 self.is_running = false; 363 self.status = "Done".to_string(); 364 self.maybe_end_session(); 365 } 366 CaptureEvent::Error(msg) => { 367 self.is_running = false; 368 self.status = format!("Error: {msg}"); 369 self.maybe_end_session(); 370 } 371 } 372 } 373 374 fn save_frame_by_idx(&mut self, idx: usize) { 375 let Some(session_id) = self.session_id else { 376 self.status = "DB session is not initialized".to_string(); 377 return; 378 }; 379 380 let Some(summary) = self.frames_by_idx.get(&idx).cloned() else { 381 self.status = format!("Frame #{idx} not in memory (it may have been evicted)"); 382 return; 383 }; 384 let Some(raw) = self.raw_by_idx.get(&idx) else { 385 self.status = format!("Raw bytes for frame #{idx} not in memory"); 386 return; 387 }; 388 389 match db::open_db(&self.db_path) { 390 Ok(conn) => match db::save_packet(&conn, session_id, &summary, raw) { 391 Ok(()) => { 392 self.saved_count += 1; 393 self.status = format!("Saved frame #{idx} (total saved: {})", self.saved_count); 394 self.refresh_saved_packets(); 395 } 396 Err(e) => { 397 self.status = format!("DB: failed to save frame #{idx}: {e}"); 398 } 399 }, 400 Err(e) => { 401 self.status = format!("DB: failed to open {}: {e}", self.db_path.display()); 402 } 403 } 404 } 405 406 fn refresh_saved_packets(&mut self) { 407 match db::open_db(&self.db_path) { 408 Ok(conn) => match db::list_saved_packets(&conn, self.session_id, MAX_SAVED_LIST) { 409 Ok(list) => { 410 self.saved_packets = list; 411 } 412 Err(e) => { 413 self.status = format!("DB: failed to list saved packets: {e}"); 414 } 415 }, 416 Err(e) => { 417 self.status = format!("DB: failed to open {}: {e}", self.db_path.display()); 418 } 419 } 420 } 421 422 fn filtered_saved_packets(&self) -> Vec<&db::SavedPacketRow> { 423 self.saved_packets 424 .iter() 425 .filter(|row| self.matches_saved(row)) 426 .collect() 427 } 428 429 fn matches_saved(&self, row: &db::SavedPacketRow) -> bool { 430 if !self.matches_text_filter(&self.filter_bssid, row.bssid.as_deref()) { 431 return false; 432 } 433 if !self.matches_text_filter(&self.filter_ssid, row.ssid.as_deref()) { 434 return false; 435 } 436 if let Some(channel) = self.channel_filter_value() { 437 let matches_channel = row.channel_mhz == Some(channel) 438 || row 439 .channel_mhz 440 .and_then(decode::radiotap::mhz_to_channel) 441 == Some(channel); 442 if !matches_channel { 443 return false; 444 } 445 } 446 if !self.matches_frame_type(row.dot11_type) { 447 return false; 448 } 449 true 450 } 451 452 fn matches_summary(&self, summary: &FrameSummary) -> bool { 453 let bssid = summary.bssid.map(|b| b.to_string()); 454 if !self.matches_text_filter( 455 &self.filter_bssid, 456 bssid.as_deref(), 457 ) { 458 return false; 459 } 460 if !self.matches_text_filter(&self.filter_ssid, summary.ssid.as_deref()) { 461 return false; 462 } 463 if let Some(channel) = self.channel_filter_value() { 464 if summary.channel != Some(channel) && summary.channel_mhz != Some(channel) { 465 return false; 466 } 467 } 468 if !self.matches_frame_type(summary.dot11_type) { 469 return false; 470 } 471 true 472 } 473 474 fn matches_frame_type(&self, dot11_type: Option<u8>) -> bool { 475 match self.filter_frame_type { 476 FrameTypeFilter::All => true, 477 FrameTypeFilter::Mgmt => dot11_type == Some(0), 478 FrameTypeFilter::Ctrl => dot11_type == Some(1), 479 FrameTypeFilter::Data => dot11_type == Some(2), 480 FrameTypeFilter::Other => dot11_type.map(|v| v > 2).unwrap_or(false), 481 } 482 } 483 484 fn matches_text_filter(&self, filter: &str, value: Option<&str>) -> bool { 485 let filter = filter.trim(); 486 if filter.is_empty() { 487 return true; 488 } 489 let Some(value) = value else { 490 return false; 491 }; 492 value.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) 493 } 494 495 fn channel_filter_value(&self) -> Option<u16> { 496 let trimmed = self.filter_channel.trim(); 497 if trimmed.is_empty() { 498 return None; 499 } 500 trimmed.parse::<u16>().ok() 501 } 502 503 fn inspect_frame(&mut self, idx: usize) { 504 let Some(summary) = self.frames_by_idx.get(&idx).cloned() else { 505 self.status = format!("Frame #{idx} not in memory"); 506 return; 507 }; 508 let raw_len = self.raw_by_idx.get(&idx).map(|raw| raw.len()); 509 self.detail = Some(detail_from_summary(&summary, raw_len)); 510 } 511 512 fn inspect_saved(&mut self, id: i64) { 513 match db::open_db(&self.db_path) { 514 Ok(conn) => match db::get_saved_packet(&conn, id) { 515 Ok(detail) => { 516 self.detail = Some(detail_from_saved(&detail)); 517 } 518 Err(e) => { 519 self.status = format!("DB: failed to load saved packet {id}: {e}"); 520 } 521 }, 522 Err(e) => { 523 self.status = format!("DB: failed to open {}: {e}", self.db_path.display()); 524 } 525 } 526 } 527 528 fn export_saved(&mut self, format: ExportFormat) { 529 self.refresh_saved_packets(); 530 let path = PathBuf::from(self.export_path_input.trim()); 531 if path.as_os_str().is_empty() { 532 self.status = "Provide an export path".to_string(); 533 return; 534 } 535 let ids: Vec<i64> = self.filtered_saved_packets().into_iter().map(|row| row.id).collect(); 536 if ids.is_empty() { 537 self.status = "No saved frames match current filters".to_string(); 538 return; 539 } 540 match db::open_db(&self.db_path) { 541 Ok(conn) => { 542 let result = match format { 543 ExportFormat::Pcap => db::export_saved_packets_pcap(&conn, &ids, &path), 544 ExportFormat::Csv => db::export_saved_packets_csv(&conn, &ids, &path), 545 ExportFormat::Json => db::export_saved_packets_json(&conn, &ids, &path), 546 }; 547 match result { 548 Ok(count) => { 549 let label = match format { 550 ExportFormat::Pcap => "pcap", 551 ExportFormat::Csv => "csv", 552 ExportFormat::Json => "json", 553 }; 554 self.status = format!("Exported {count} saved frames to {label}: {}", path.display()); 555 } 556 Err(e) => { 557 self.status = format!("Export failed: {e}"); 558 } 559 } 560 } 561 Err(e) => { 562 self.status = format!("DB: failed to open {}: {e}", self.db_path.display()); 563 } 564 } 565 } 566 } 567 568 impl Application for WiFiLensApp { 569 type Executor = executor::Default; 570 type Message = Message; 571 type Theme = Theme; 572 type Flags = (); 573 574 fn new(_flags: ()) -> (Self, Command<Message>) { 575 let mut app = Self { 576 status: "Ready".to_string(), 577 pcap_path_input: "".to_string(), 578 db_path_input: "wifilens.sqlite".to_string(), 579 var_tmp_files: Vec::new(), 580 selected_var_tmp: None, 581 capture: None, 582 is_running: false, 583 agg: Aggregator::new(MAX_TIMELINE, MAX_APS), 584 mgmt_only: true, 585 tail_capture: true, 586 show_saved: false, 587 filter_bssid: String::new(), 588 filter_ssid: String::new(), 589 filter_channel: String::new(), 590 filter_frame_type: FrameTypeFilter::All, 591 export_path_input: "saved_frames.pcap".to_string(), 592 frames_by_idx: HashMap::new(), 593 raw_by_idx: HashMap::new(), 594 raw_order: VecDeque::new(), 595 session_id: None, 596 db_path: PathBuf::from("wifilens.sqlite"), 597 saved_count: 0, 598 saved_packets: Vec::new(), 599 glow_phase: 0.0, 600 hovered_panel: None, 601 detail: None, 602 }; 603 604 if cfg!(target_os = "macos") { 605 app.refresh_var_tmp(); 606 } 607 608 (app, Command::none()) 609 } 610 611 fn title(&self) -> String { 612 "WiFiLens MVP (pcap viewer + SQLite saves)".to_string() 613 } 614 615 fn style(&self) -> iced::theme::Application { 616 let pulse = self.glow_pulse(); 617 iced::theme::Application::custom(NeonApp { pulse }) 618 } 619 620 fn update(&mut self, message: Message) -> Command<Message> { 621 match message { 622 Message::Tick => { 623 self.glow_phase += 0.035; 624 if self.glow_phase > TAU { 625 self.glow_phase -= TAU; 626 } 627 self.drain_capture_events(); 628 } 629 630 Message::StopCapture => { 631 self.stop_capture(); 632 self.status = "Stopped".to_string(); 633 self.maybe_end_session(); 634 } 635 636 Message::RefreshVarTmp => { 637 self.refresh_var_tmp(); 638 } 639 640 Message::UseVarTmpFile(i) => { 641 self.selected_var_tmp = Some(i); 642 if let Some(row) = self.var_tmp_files.get(i) { 643 self.pcap_path_input = row.path.display().to_string(); 644 } 645 } 646 647 Message::UseNewestVarTmp => { 648 if let Some(first) = self.var_tmp_files.get(0) { 649 self.pcap_path_input = first.path.display().to_string(); 650 self.selected_var_tmp = Some(0); 651 } 652 } 653 654 Message::PcapPathChanged(v) => { 655 self.pcap_path_input = v; 656 } 657 658 Message::DbPathChanged(v) => { 659 self.db_path_input = v.clone(); 660 self.db_path = PathBuf::from(v); 661 } 662 663 Message::OpenWirelessDiagnostics => { 664 #[cfg(target_os = "macos")] 665 { 666 let _ = std::process::Command::new("open") 667 .args(["-a", "Wireless Diagnostics"]) 668 .spawn(); 669 self.status = 670 "Opened Wireless Diagnostics (Window → Sniffer → Start)".to_string(); 671 } 672 #[cfg(not(target_os = "macos"))] 673 { 674 self.status = "Wireless Diagnostics is macOS-only".to_string(); 675 } 676 } 677 678 Message::LoadFromPath => { 679 let p = PathBuf::from(self.pcap_path_input.trim()); 680 if p.as_os_str().is_empty() { 681 self.status = "Provide a pcap/pcapng file path".to_string(); 682 } else { 683 self.start_capture_from_path(p); 684 } 685 } 686 687 Message::ToggleMgmtOnly(v) => { 688 self.mgmt_only = v; 689 self.status = if v { 690 "Filtering: management frames only".to_string() 691 } else { 692 "Filtering: all frames".to_string() 693 }; 694 } 695 696 Message::ToggleTail(v) => { 697 self.tail_capture = v; 698 self.status = if v { 699 "Tail mode enabled".to_string() 700 } else { 701 "Tail mode disabled".to_string() 702 }; 703 } 704 705 Message::ToggleShowSaved(v) => { 706 self.show_saved = v; 707 if v { 708 self.refresh_saved_packets(); 709 } 710 } 711 712 Message::FilterBssidChanged(v) => { 713 self.filter_bssid = v; 714 } 715 716 Message::FilterSsidChanged(v) => { 717 self.filter_ssid = v; 718 } 719 720 Message::FilterChannelChanged(v) => { 721 self.filter_channel = v; 722 } 723 724 Message::FilterFrameTypeChanged(v) => { 725 self.filter_frame_type = v; 726 } 727 728 Message::ExportPathChanged(v) => { 729 self.export_path_input = v; 730 } 731 732 Message::RefreshSaved => { 733 self.refresh_saved_packets(); 734 } 735 736 Message::ExportSavedPcap => { 737 self.export_saved(ExportFormat::Pcap); 738 } 739 740 Message::ExportSavedCsv => { 741 self.export_saved(ExportFormat::Csv); 742 } 743 744 Message::ExportSavedJson => { 745 self.export_saved(ExportFormat::Json); 746 } 747 748 Message::SaveFrame(idx) => { 749 self.save_frame_by_idx(idx); 750 } 751 752 Message::InspectFrame(idx) => { 753 self.inspect_frame(idx); 754 } 755 756 Message::InspectSaved(id) => { 757 self.inspect_saved(id); 758 } 759 760 Message::HoverPanel(panel) => { 761 self.hovered_panel = panel; 762 } 763 } 764 765 Command::none() 766 } 767 768 fn view(&self) -> Element<'_, Message> { 769 let pulse = self.glow_pulse(); 770 let heading_color = mix_color(COLOR_TEXT, COLOR_ACCENT, 0.1 + 0.08 * pulse); 771 let status_color = mix_color(COLOR_TEXT_DIM, COLOR_ACCENT, 0.12 + 0.06 * pulse); 772 let divider_style = 773 |color: Color| iced::theme::Container::Custom(Box::new(NeonDivider { color })); 774 let button_style = |dim: bool| iced::theme::Button::custom(NeonButton { pulse, dim }); 775 let input_style = || iced::theme::TextInput::Custom(Box::new(NeonTextInput { pulse })); 776 let corner_line = |label: &str| { 777 row![ 778 text("+--").size(10).style(COLOR_ACCENT_DIM), 779 text(label).size(10).style(heading_color), 780 container(text(". . . . . . . . .").size(10).style(COLOR_ACCENT_DIM)) 781 .width(Length::Fill), 782 text("--+").size(10).style(COLOR_ACCENT_DIM), 783 ] 784 .spacing(6) 785 .align_items(iced::Alignment::Center) 786 }; 787 let corner_footer = || { 788 row![ 789 text("+--").size(10).style(COLOR_ACCENT_DIM), 790 container(text(". . . . . . . . .").size(10).style(COLOR_ACCENT_DIM)) 791 .width(Length::Fill), 792 text("--+").size(10).style(COLOR_ACCENT_DIM), 793 ] 794 .spacing(6) 795 .align_items(iced::Alignment::Center) 796 }; 797 let detail_panel: Element<'_, Message> = if let Some(detail) = &self.detail { 798 let mut col = column![text(&detail.title).style(heading_color)]; 799 for line in &detail.lines { 800 col = col.push(text(line).style(COLOR_TEXT_DIM)); 801 } 802 col.spacing(4).into() 803 } else { 804 text("Select a frame to inspect") 805 .size(10) 806 .style(COLOR_TEXT_DIM) 807 .into() 808 }; 809 let top_bar = column![ 810 row![ 811 container( 812 text("DR NOSE // TERMINAL OVERLAY") 813 .size(16) 814 .style(heading_color) 815 ) 816 .width(Length::Fill), 817 button("Load") 818 .style(button_style(false)) 819 .on_press(Message::LoadFromPath), 820 button("Stop") 821 .style(button_style(true)) 822 .on_press(if self.is_running { 823 Message::StopCapture 824 } else { 825 Message::Tick 826 }), 827 button("Refresh /var/tmp") 828 .style(button_style(true)) 829 .on_press(Message::RefreshVarTmp), 830 button("Use newest /var/tmp") 831 .style(button_style(true)) 832 .on_press(Message::UseNewestVarTmp), 833 button("Open Wireless Diagnostics") 834 .style(button_style(true)) 835 .on_press(Message::OpenWirelessDiagnostics), 836 ] 837 .spacing(10) 838 .align_items(iced::Alignment::Center), 839 container( 840 text("STATUS | LINK | STORAGE | STREAM | ANALYTICS") 841 .size(10) 842 .style(COLOR_TEXT_DIM), 843 ) 844 .width(Length::Fill), 845 container(text("")) 846 .style(divider_style(COLOR_ACCENT_DIM)) 847 .height(Length::Fixed(1.0)), 848 ] 849 .spacing(6); 850 851 let inputs = column![ 852 row![ 853 text("PCAP path:") 854 .style(heading_color) 855 .width(Length::Shrink), 856 text_input("/var/tmp/*.pcap", &self.pcap_path_input) 857 .style(input_style()) 858 .on_input(Message::PcapPathChanged) 859 .width(Length::Fill), 860 ] 861 .spacing(8), 862 row![ 863 text("SQLite DB:") 864 .style(heading_color) 865 .width(Length::Shrink), 866 text_input("wifilens.sqlite", &self.db_path_input) 867 .style(input_style()) 868 .on_input(Message::DbPathChanged) 869 .width(Length::Fill), 870 ] 871 .spacing(8), 872 row![ 873 checkbox("management frames only", self.mgmt_only) 874 .on_toggle(Message::ToggleMgmtOnly), 875 checkbox("tail capture", self.tail_capture).on_toggle(Message::ToggleTail), 876 checkbox("show saved frames", self.show_saved) 877 .on_toggle(Message::ToggleShowSaved), 878 button("Refresh saved") 879 .style(button_style(true)) 880 .on_press(Message::RefreshSaved), 881 text(format!("saved: {}", self.saved_count)).style(COLOR_TEXT_DIM), 882 text(if self.is_running { "running" } else { "idle" }).style(status_color), 883 ] 884 .spacing(16), 885 row![ 886 text("BSSID filter:") 887 .style(heading_color) 888 .width(Length::Shrink), 889 text_input("aa:bb", &self.filter_bssid) 890 .style(input_style()) 891 .on_input(Message::FilterBssidChanged) 892 .width(Length::FillPortion(2)), 893 text("SSID filter:") 894 .style(heading_color) 895 .width(Length::Shrink), 896 text_input("my-wifi", &self.filter_ssid) 897 .style(input_style()) 898 .on_input(Message::FilterSsidChanged) 899 .width(Length::FillPortion(3)), 900 ] 901 .spacing(8), 902 row![ 903 text("Channel:") 904 .style(heading_color) 905 .width(Length::Shrink), 906 text_input("6", &self.filter_channel) 907 .style(input_style()) 908 .on_input(Message::FilterChannelChanged) 909 .width(Length::Fixed(80.0)), 910 text("Type:") 911 .style(heading_color) 912 .width(Length::Shrink), 913 pick_list( 914 FrameTypeFilter::ALL, 915 Some(self.filter_frame_type), 916 Message::FilterFrameTypeChanged, 917 ) 918 .width(Length::Fixed(120.0)), 919 ] 920 .spacing(8), 921 row![ 922 text("Export path:") 923 .style(heading_color) 924 .width(Length::Shrink), 925 text_input("saved_frames.pcap", &self.export_path_input) 926 .style(input_style()) 927 .on_input(Message::ExportPathChanged) 928 .width(Length::Fill), 929 button("Export PCAP") 930 .style(button_style(true)) 931 .on_press(Message::ExportSavedPcap), 932 button("Export CSV") 933 .style(button_style(true)) 934 .on_press(Message::ExportSavedCsv), 935 button("Export JSON") 936 .style(button_style(true)) 937 .on_press(Message::ExportSavedJson), 938 ] 939 .spacing(8), 940 ] 941 .spacing(8); 942 943 let var_tmp_list = { 944 let mut col = column![].spacing(6); 945 let now = SystemTime::now(); 946 for (i, f) in self.var_tmp_files.iter().enumerate() { 947 let name = f 948 .path 949 .file_name() 950 .and_then(|s| s.to_str()) 951 .unwrap_or("(unknown)"); 952 953 let marker = if self.selected_var_tmp == Some(i) { 954 "▶" 955 } else { 956 " " 957 }; 958 let age = now 959 .duration_since(f.modified) 960 .unwrap_or_else(|_| Duration::from_secs(0)); 961 let label = format!( 962 "{} {} ({} bytes, {}s ago)", 963 marker, 964 name, 965 f.size, 966 age.as_secs() 967 ); 968 col = col.push( 969 button(text(label).style(COLOR_TEXT)) 970 .style(button_style(true)) 971 .on_press(Message::UseVarTmpFile(i)) 972 .width(Length::Fill), 973 ); 974 } 975 scrollable(col).height(Length::Fill) 976 }; 977 978 let var_tmp_panel = column![ 979 corner_line("VAR/TMP CAPTURES"), 980 var_tmp_list, 981 corner_footer(), 982 ] 983 .spacing(6); 984 985 let ap_panel = { 986 let mut col = column![].spacing(4); 987 for ap in self.agg.ap_states_sorted().into_iter().take(MAX_APS) { 988 let ssid = ap.ssid.as_deref().unwrap_or("-"); 989 let ch = ap 990 .channel 991 .map(|c| c.to_string()) 992 .or_else(|| ap.channel_mhz.map(|m| format!("{}MHz", m))) 993 .unwrap_or_else(|| "-".into()); 994 let rssi = ap 995 .last_rssi_dbm 996 .map(|r| r.to_string()) 997 .unwrap_or_else(|| "?".into()); 998 let line = format!( 999 "{} ssid={} ch={} rssi={}dBm frames={}", 1000 ap.bssid, ssid, ch, rssi, ap.total_frames 1001 ); 1002 col = col.push(text(line).style(COLOR_TEXT)); 1003 } 1004 scrollable(col).height(Length::Fill) 1005 }; 1006 1007 let ap_panel = column![corner_line("ACCESS POINTS"), ap_panel, corner_footer()].spacing(6); 1008 1009 let timeline_panel_body = { 1010 let mut col = column![].spacing(4); 1011 let timeline = &self.agg.timeline; 1012 let filtered: Vec<&FrameSummary> = timeline 1013 .iter() 1014 .filter(|f| self.matches_summary(f)) 1015 .collect(); 1016 let start = filtered.len().saturating_sub(400); 1017 for f in filtered.iter().skip(start) { 1018 let kind = frame_kind(f); 1019 let bssid = f.bssid.map(|m| m.to_string()).unwrap_or_else(|| "-".into()); 1020 let ssid = f.ssid.as_deref().unwrap_or("-"); 1021 let ch = f 1022 .channel 1023 .map(|c| c.to_string()) 1024 .or_else(|| f.channel_mhz.map(|m| format!("{}MHz", m))) 1025 .unwrap_or_else(|| "-".into()); 1026 let rssi = f 1027 .rssi_dbm 1028 .map(|r| r.to_string()) 1029 .unwrap_or_else(|| "?".into()); 1030 let line = format!( 1031 "[#{:05}] {:10} bssid={} ssid={} ch={} rssi={}dBm", 1032 f.idx, kind, bssid, ssid, ch, rssi 1033 ); 1034 1035 col = col.push( 1036 row![ 1037 text(line).style(COLOR_TEXT).width(Length::Fill), 1038 button("Inspect") 1039 .style(button_style(true)) 1040 .on_press(Message::InspectFrame(f.idx)), 1041 button("Save") 1042 .style(button_style(true)) 1043 .on_press(Message::SaveFrame(f.idx)), 1044 ] 1045 .spacing(8), 1046 ); 1047 } 1048 scrollable(col).height(Length::Fill) 1049 }; 1050 1051 let saved_panel_body = { 1052 let mut col = column![].spacing(4); 1053 let filtered = self.filtered_saved_packets(); 1054 for row in filtered.iter().take(400) { 1055 let kind = frame_kind_from_type_subtype(row.dot11_type, row.dot11_subtype); 1056 let bssid = row.bssid.as_deref().unwrap_or("-"); 1057 let ssid = row.ssid.as_deref().unwrap_or("-"); 1058 let ch = row 1059 .channel_mhz 1060 .and_then(decode::radiotap::mhz_to_channel) 1061 .map(|c| c.to_string()) 1062 .or_else(|| row.channel_mhz.map(|m| format!("{}MHz", m))) 1063 .unwrap_or_else(|| "-".into()); 1064 let rssi = row 1065 .rssi_dbm 1066 .map(|r| r.to_string()) 1067 .unwrap_or_else(|| "?".into()); 1068 let line = format!( 1069 "[id:{:05}] {:10} bssid={} ssid={} ch={} rssi={}dBm", 1070 row.id, kind, bssid, ssid, ch, rssi 1071 ); 1072 1073 col = col.push( 1074 row![ 1075 text(line).style(COLOR_TEXT).width(Length::Fill), 1076 button("Inspect") 1077 .style(button_style(true)) 1078 .on_press(Message::InspectSaved(row.id)), 1079 ] 1080 .spacing(8), 1081 ); 1082 } 1083 scrollable(col).height(Length::Fill) 1084 }; 1085 1086 let right_panel_title = if self.show_saved { 1087 "SAVED FRAMES" 1088 } else { 1089 "FRAME TIMELINE" 1090 }; 1091 let right_panel = column![ 1092 corner_line(right_panel_title), 1093 if self.show_saved { 1094 saved_panel_body 1095 } else { 1096 timeline_panel_body 1097 }, 1098 corner_footer() 1099 ] 1100 .spacing(6); 1101 1102 let v_divider = || { 1103 container(text("")) 1104 .style(divider_style(COLOR_ACCENT_DIM)) 1105 .width(Length::Fixed(1.0)) 1106 .height(Length::Fill) 1107 }; 1108 1109 let body = row![ 1110 container(panel_block( 1111 PanelId::VarTmp, 1112 self.hovered_panel == Some(PanelId::VarTmp), 1113 pulse, 1114 COLOR_PANEL, 1115 var_tmp_panel.into() 1116 )) 1117 .width(Length::FillPortion(2)), 1118 v_divider(), 1119 container(panel_block( 1120 PanelId::AccessPoints, 1121 self.hovered_panel == Some(PanelId::AccessPoints), 1122 pulse, 1123 COLOR_PANEL, 1124 ap_panel.into() 1125 )) 1126 .width(Length::FillPortion(3)), 1127 v_divider(), 1128 container(panel_block( 1129 PanelId::Timeline, 1130 self.hovered_panel == Some(PanelId::Timeline), 1131 pulse, 1132 COLOR_PANEL, 1133 right_panel.into() 1134 )) 1135 .width(Length::FillPortion(5)), 1136 ] 1137 .spacing(8) 1138 .height(Length::Fill); 1139 1140 let root = column![ 1141 container(panel_block( 1142 PanelId::TopBar, 1143 self.hovered_panel == Some(PanelId::TopBar), 1144 pulse, 1145 COLOR_PANEL_ALT, 1146 top_bar.into() 1147 )) 1148 .width(Length::Fill), 1149 container(panel_block( 1150 PanelId::Inputs, 1151 self.hovered_panel == Some(PanelId::Inputs), 1152 pulse, 1153 COLOR_PANEL_ALT, 1154 inputs.into() 1155 )) 1156 .width(Length::Fill), 1157 container(panel_block( 1158 PanelId::Status, 1159 self.hovered_panel == Some(PanelId::Status), 1160 pulse, 1161 COLOR_PANEL_ALT, 1162 text(&self.status).style(status_color).into(), 1163 )) 1164 .width(Length::Fill), 1165 container(panel_block( 1166 PanelId::Telemetry, 1167 self.hovered_panel == Some(PanelId::Telemetry), 1168 pulse, 1169 COLOR_PANEL_ALT, 1170 detail_panel, 1171 )) 1172 .width(Length::Fill), 1173 body, 1174 ] 1175 .spacing(10) 1176 .padding(14) 1177 .height(Length::Fill); 1178 1179 container(root) 1180 .width(Length::Fill) 1181 .height(Length::Fill) 1182 .into() 1183 } 1184 1185 fn subscription(&self) -> Subscription<Message> { 1186 // A simple tick to drain capture-thread events. 1187 time::every(Duration::from_millis(80)).map(|_| Message::Tick) 1188 } 1189 } 1190 1191 fn frame_kind(f: &FrameSummary) -> &'static str { 1192 frame_kind_from_type_subtype(f.dot11_type, f.dot11_subtype) 1193 } 1194 1195 fn frame_kind_from_type_subtype(dot11_type: Option<u8>, dot11_subtype: Option<u8>) -> &'static str { 1196 match dot11_type { 1197 Some(0) => match dot11_subtype { 1198 Some(8) => "BEACON", 1199 Some(4) => "PROBE_REQ", 1200 Some(5) => "PROBE_RESP", 1201 Some(0) => "ASSOC_REQ", 1202 Some(1) => "ASSOC_RESP", 1203 Some(11) => "AUTH", 1204 Some(12) => "DEAUTH", 1205 Some(10) => "DISASSOC", 1206 _ => "MGMT", 1207 }, 1208 Some(1) => "CTRL", 1209 Some(2) => "DATA", 1210 Some(_) => "OTHER", 1211 None => "UNKNOWN", 1212 } 1213 } 1214 1215 fn detail_from_summary(summary: &FrameSummary, raw_len: Option<usize>) -> FrameDetail { 1216 let kind = frame_kind_from_type_subtype(summary.dot11_type, summary.dot11_subtype); 1217 let bssid = summary.bssid.map(|m| m.to_string()).unwrap_or_else(|| "-".into()); 1218 let ssid = summary.ssid.as_deref().unwrap_or("-"); 1219 let addr1 = summary.addr1.map(|m| m.to_string()).unwrap_or_else(|| "-".into()); 1220 let addr2 = summary.addr2.map(|m| m.to_string()).unwrap_or_else(|| "-".into()); 1221 let addr3 = summary.addr3.map(|m| m.to_string()).unwrap_or_else(|| "-".into()); 1222 let channel_line = match (summary.channel, summary.channel_mhz) { 1223 (Some(ch), Some(mhz)) => format!("Channel: {ch} ({mhz} MHz)"), 1224 (Some(ch), None) => format!("Channel: {ch}"), 1225 (None, Some(mhz)) => format!("Channel: {mhz} MHz"), 1226 (None, None) => "Channel: -".to_string(), 1227 }; 1228 let rssi = summary 1229 .rssi_dbm 1230 .map(|r| r.to_string()) 1231 .unwrap_or_else(|| "-".into()); 1232 let raw_len = raw_len.unwrap_or(0); 1233 1234 FrameDetail { 1235 title: format!("Timeline frame #{}", summary.idx), 1236 lines: vec![ 1237 format!("Type: {kind} (type={:?} subtype={:?})", summary.dot11_type, summary.dot11_subtype), 1238 format!("Timestamp: {} ns", summary.ts_ns), 1239 format!("BSSID: {bssid}"), 1240 format!("SSID: {ssid}"), 1241 channel_line, 1242 format!("RSSI: {rssi} dBm"), 1243 format!("Addr1: {addr1}"), 1244 format!("Addr2: {addr2}"), 1245 format!("Addr3: {addr3}"), 1246 format!("Linktype: {}", summary.linktype), 1247 format!("Raw length: {raw_len} bytes"), 1248 ], 1249 } 1250 } 1251 1252 fn detail_from_saved(detail: &db::SavedPacketDetail) -> FrameDetail { 1253 let row = &detail.row; 1254 let kind = frame_kind_from_type_subtype(row.dot11_type, row.dot11_subtype); 1255 let bssid = row.bssid.as_deref().unwrap_or("-"); 1256 let ssid = row.ssid.as_deref().unwrap_or("-"); 1257 let addr1 = row.addr1.as_deref().unwrap_or("-"); 1258 let addr2 = row.addr2.as_deref().unwrap_or("-"); 1259 let addr3 = row.addr3.as_deref().unwrap_or("-"); 1260 let channel_line = match row.channel_mhz { 1261 Some(mhz) => { 1262 let ch = decode::radiotap::mhz_to_channel(mhz); 1263 match ch { 1264 Some(ch) => format!("Channel: {ch} ({mhz} MHz)"), 1265 None => format!("Channel: {mhz} MHz"), 1266 } 1267 } 1268 None => "Channel: -".to_string(), 1269 }; 1270 let rssi = row 1271 .rssi_dbm 1272 .map(|r| r.to_string()) 1273 .unwrap_or_else(|| "-".into()); 1274 let linktype = row 1275 .linktype 1276 .map(|lt| lt.to_string()) 1277 .unwrap_or_else(|| "-".into()); 1278 1279 FrameDetail { 1280 title: format!("Saved packet id {}", row.id), 1281 lines: vec![ 1282 format!("Type: {kind} (type={:?} subtype={:?})", row.dot11_type, row.dot11_subtype), 1283 format!("Timestamp: {} ns", row.ts_ns), 1284 format!("BSSID: {bssid}"), 1285 format!("SSID: {ssid}"), 1286 channel_line, 1287 format!("RSSI: {rssi} dBm"), 1288 format!("Addr1: {addr1}"), 1289 format!("Addr2: {addr2}"), 1290 format!("Addr3: {addr3}"), 1291 format!("Linktype: {linktype}"), 1292 format!("Raw length: {} bytes", detail.raw.len()), 1293 ], 1294 } 1295 } 1296 1297 fn capture_decode_file( 1298 path: PathBuf, 1299 tx: mpsc::Sender<CaptureEvent>, 1300 stop: Arc<AtomicBool>, 1301 tail: bool, 1302 ) -> Result<(), String> { 1303 use pcap::{Capture, Offline}; 1304 1305 let source = path.display().to_string(); 1306 tx.send(CaptureEvent::Started { 1307 path: source.clone(), 1308 }) 1309 .map_err(|e| e.to_string())?; 1310 1311 let mut idx: usize = 0; 1312 while !stop.load(Ordering::Relaxed) { 1313 let mut cap = Capture::<Offline>::from_file(&path).map_err(|e| e.to_string())?; 1314 let linktype = cap.get_datalink().0 as u32; 1315 1316 for _ in 0..idx { 1317 if stop.load(Ordering::Relaxed) { 1318 break; 1319 } 1320 match cap.next_packet() { 1321 Ok(_) => {} 1322 Err(pcap::Error::NoMorePackets) => break, 1323 Err(e) => return Err(e.to_string()), 1324 } 1325 } 1326 1327 if stop.load(Ordering::Relaxed) { 1328 break; 1329 } 1330 1331 let mut got_new = false; 1332 loop { 1333 if stop.load(Ordering::Relaxed) { 1334 break; 1335 } 1336 match cap.next_packet() { 1337 Ok(pkt) => { 1338 got_new = true; 1339 let ts = pkt.header.ts; 1340 let ts_ns = (ts.tv_sec as i128) * 1_000_000_000i128 1341 + (ts.tv_usec as i128) * 1_000i128; 1342 1343 if let Ok((summary, raw)) = 1344 decode::decode_frame(idx, ts_ns, linktype, pkt.data) 1345 { 1346 let _ = tx.send(CaptureEvent::Frame { summary, raw }); 1347 } 1348 1349 idx += 1; 1350 } 1351 Err(pcap::Error::NoMorePackets) => break, 1352 Err(e) => return Err(e.to_string()), 1353 } 1354 } 1355 1356 if !tail { 1357 break; 1358 } 1359 1360 if !got_new { 1361 std::thread::sleep(Duration::from_millis(250)); 1362 } else { 1363 std::thread::sleep(Duration::from_millis(40)); 1364 } 1365 } 1366 1367 let _ = tx.send(CaptureEvent::Done); 1368 Ok(()) 1369 } 1370 1371 const COLOR_BG: Color = Color { 1372 r: 0.01, 1373 g: 0.01, 1374 b: 0.015, 1375 a: 1.0, 1376 }; 1377 const COLOR_BG_GLOW: Color = Color { 1378 r: 0.015, 1379 g: 0.02, 1380 b: 0.025, 1381 a: 1.0, 1382 }; 1383 const COLOR_PANEL: Color = Color { 1384 r: 0.02, 1385 g: 0.025, 1386 b: 0.03, 1387 a: 0.98, 1388 }; 1389 const COLOR_PANEL_ALT: Color = Color { 1390 r: 0.02, 1391 g: 0.03, 1392 b: 0.035, 1393 a: 0.98, 1394 }; 1395 const COLOR_TEXT: Color = Color { 1396 r: 0.85, 1397 g: 0.88, 1398 b: 0.9, 1399 a: 1.0, 1400 }; 1401 const COLOR_TEXT_DIM: Color = Color { 1402 r: 0.6, 1403 g: 0.64, 1404 b: 0.68, 1405 a: 1.0, 1406 }; 1407 const COLOR_ACCENT: Color = Color { 1408 r: 0.55, 1409 g: 0.72, 1410 b: 0.8, 1411 a: 1.0, 1412 }; 1413 const COLOR_ACCENT_DIM: Color = Color { 1414 r: 0.2, 1415 g: 0.3, 1416 b: 0.36, 1417 a: 1.0, 1418 }; 1419 const SHARE_TECH_MONO: Font = Font::with_name("Share Tech Mono"); 1420 1421 fn mix_color(a: Color, b: Color, t: f32) -> Color { 1422 let t = t.clamp(0.0, 1.0); 1423 Color { 1424 r: a.r + (b.r - a.r) * t, 1425 g: a.g + (b.g - a.g) * t, 1426 b: a.b + (b.b - a.b) * t, 1427 a: a.a + (b.a - a.a) * t, 1428 } 1429 } 1430 1431 fn panel_appearance(pulse: f32, background: Color) -> iced::widget::container::Appearance { 1432 let edge = mix_color(COLOR_ACCENT_DIM, COLOR_ACCENT, 0.18 + 0.25 * pulse); 1433 iced::widget::container::Appearance { 1434 text_color: Some(COLOR_TEXT), 1435 background: Some(Background::Color(background)), 1436 border: Border { 1437 color: edge, 1438 width: 0.0, 1439 radius: 0.0.into(), 1440 }, 1441 shadow: Shadow { 1442 color: Color { a: 0.08, ..edge }, 1443 offset: Vector::new(0.0, 0.0), 1444 blur_radius: 2.0, 1445 }, 1446 } 1447 } 1448 1449 #[derive(Clone, Copy)] 1450 struct NeonApp { 1451 pulse: f32, 1452 } 1453 1454 impl iced::application::StyleSheet for NeonApp { 1455 type Style = Theme; 1456 1457 fn appearance(&self, _style: &Self::Style) -> iced::application::Appearance { 1458 iced::application::Appearance { 1459 background_color: mix_color(COLOR_BG, COLOR_BG_GLOW, self.pulse * 0.15), 1460 text_color: COLOR_TEXT, 1461 } 1462 } 1463 } 1464 1465 #[derive(Clone, Copy)] 1466 struct NeonPanel { 1467 pulse: f32, 1468 background: Color, 1469 } 1470 1471 impl iced::widget::container::StyleSheet for NeonPanel { 1472 type Style = Theme; 1473 1474 fn appearance(&self, _style: &Self::Style) -> iced::widget::container::Appearance { 1475 panel_appearance(self.pulse, self.background) 1476 } 1477 } 1478 1479 #[derive(Clone, Copy)] 1480 struct NeonDivider { 1481 color: Color, 1482 } 1483 1484 impl iced::widget::container::StyleSheet for NeonDivider { 1485 type Style = Theme; 1486 1487 fn appearance(&self, _style: &Self::Style) -> iced::widget::container::Appearance { 1488 iced::widget::container::Appearance { 1489 text_color: None, 1490 background: Some(Background::Color(self.color)), 1491 border: Border::default(), 1492 shadow: Shadow::default(), 1493 } 1494 } 1495 } 1496 1497 fn panel_block<'a>( 1498 panel: PanelId, 1499 active: bool, 1500 pulse: f32, 1501 background: Color, 1502 content: Element<'a, Message>, 1503 ) -> Element<'a, Message> { 1504 let line_width = if active { 3.0 } else { 1.0 }; 1505 let line_color = mix_color( 1506 COLOR_ACCENT_DIM, 1507 COLOR_ACCENT, 1508 if active { 1509 0.4 + 0.3 * pulse 1510 } else { 1511 0.15 + 0.15 * pulse 1512 }, 1513 ); 1514 let line = container(text("")) 1515 .style(iced::theme::Container::Custom(Box::new(NeonDivider { 1516 color: line_color, 1517 }))) 1518 .width(Length::Fixed(line_width)) 1519 .height(Length::Fill); 1520 let body = row![ 1521 line, 1522 container(content) 1523 .style(iced::theme::Container::Custom(Box::new(NeonPanel { 1524 pulse, 1525 background 1526 }))) 1527 .width(Length::Fill) 1528 .padding(10), 1529 ] 1530 .spacing(8); 1531 mouse_area(body) 1532 .on_enter(Message::HoverPanel(Some(panel))) 1533 .on_exit(Message::HoverPanel(None)) 1534 .into() 1535 } 1536 1537 #[derive(Clone, Copy)] 1538 struct NeonButton { 1539 pulse: f32, 1540 dim: bool, 1541 } 1542 1543 impl iced::widget::button::StyleSheet for NeonButton { 1544 type Style = Theme; 1545 1546 fn active(&self, _style: &Self::Style) -> iced::widget::button::Appearance { 1547 let edge = mix_color(COLOR_ACCENT_DIM, COLOR_ACCENT, 0.22 + 0.22 * self.pulse); 1548 iced::widget::button::Appearance { 1549 shadow_offset: Vector::new(0.0, 0.0), 1550 background: Some(Background::Color(mix_color(COLOR_PANEL, COLOR_BG, 0.2))), 1551 text_color: if self.dim { COLOR_TEXT_DIM } else { COLOR_TEXT }, 1552 border: Border { 1553 color: edge, 1554 width: 1.0, 1555 radius: 0.0.into(), 1556 }, 1557 shadow: Shadow { 1558 color: Color { a: 0.06, ..edge }, 1559 offset: Vector::new(0.0, 0.0), 1560 blur_radius: 1.5, 1561 }, 1562 } 1563 } 1564 1565 fn hovered(&self, style: &Self::Style) -> iced::widget::button::Appearance { 1566 let mut active = self.active(style); 1567 active.background = Some(Background::Color(mix_color( 1568 COLOR_PANEL, 1569 COLOR_BG_GLOW, 1570 0.18, 1571 ))); 1572 active 1573 } 1574 1575 fn pressed(&self, style: &Self::Style) -> iced::widget::button::Appearance { 1576 let mut active = self.active(style); 1577 active.background = Some(Background::Color(mix_color(COLOR_PANEL, COLOR_BG, 0.35))); 1578 active 1579 } 1580 } 1581 1582 #[derive(Clone, Copy)] 1583 struct NeonTextInput { 1584 pulse: f32, 1585 } 1586 1587 impl iced::widget::text_input::StyleSheet for NeonTextInput { 1588 type Style = Theme; 1589 1590 fn active(&self, _style: &Self::Style) -> iced::widget::text_input::Appearance { 1591 iced::widget::text_input::Appearance { 1592 background: Background::Color(COLOR_BG_GLOW), 1593 border: Border { 1594 color: mix_color(COLOR_ACCENT_DIM, COLOR_ACCENT, 0.15 + 0.2 * self.pulse), 1595 width: 1.0, 1596 radius: 0.0.into(), 1597 }, 1598 icon_color: COLOR_TEXT_DIM, 1599 } 1600 } 1601 1602 fn focused(&self, _style: &Self::Style) -> iced::widget::text_input::Appearance { 1603 iced::widget::text_input::Appearance { 1604 background: Background::Color(COLOR_PANEL), 1605 border: Border { 1606 color: mix_color(COLOR_ACCENT_DIM, COLOR_ACCENT, 0.25 + 0.2 * self.pulse), 1607 width: 1.2, 1608 radius: 0.0.into(), 1609 }, 1610 icon_color: COLOR_TEXT, 1611 } 1612 } 1613 1614 fn placeholder_color(&self, _style: &Self::Style) -> Color { 1615 COLOR_TEXT_DIM 1616 } 1617 1618 fn value_color(&self, _style: &Self::Style) -> Color { 1619 COLOR_TEXT 1620 } 1621 1622 fn disabled_color(&self, _style: &Self::Style) -> Color { 1623 Color { 1624 a: 0.5, 1625 ..COLOR_TEXT_DIM 1626 } 1627 } 1628 1629 fn selection_color(&self, _style: &Self::Style) -> Color { 1630 mix_color(COLOR_ACCENT_DIM, COLOR_ACCENT, 0.35) 1631 } 1632 1633 fn hovered(&self, style: &Self::Style) -> iced::widget::text_input::Appearance { 1634 let mut active = self.active(style); 1635 active.border.color = mix_color(COLOR_ACCENT_DIM, COLOR_ACCENT, 0.25 + 0.2 * self.pulse); 1636 active 1637 } 1638 1639 fn disabled(&self, _style: &Self::Style) -> iced::widget::text_input::Appearance { 1640 iced::widget::text_input::Appearance { 1641 background: Background::Color(mix_color(COLOR_BG, COLOR_PANEL, 0.5)), 1642 border: Border { 1643 color: COLOR_ACCENT_DIM, 1644 width: 1.0, 1645 radius: 0.0.into(), 1646 }, 1647 icon_color: COLOR_TEXT_DIM, 1648 } 1649 } 1650 }