/ src / main.rs
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  }