From 16839cfd934c4485fe61a4552eb151ba20de1eb2 Mon Sep 17 00:00:00 2001 From: Josh <159950906+MayBeLinux@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:11:16 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=92=A1=20Add=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/display/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/display/mod.rs b/src/display/mod.rs index 9ade041..6f99c18 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -1,3 +1,10 @@ +// MODULE POUR L'INTERFACE UTILISATEUR TUI +// Ce module utilise les crates 'crossterm' et 'ratatui' pour créer une interface utilisateur en mode texte. +// L'interface permet d'afficher des informations et d'interagir avec l'utilisateur via le terminal. + + + + use crossterm::{ event::{self, Event, KeyCode}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, From c25d122cdfb7a1faf5119da0cf7b00423e77d5b2 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 16 Feb 2026 16:52:47 +0100 Subject: [PATCH 2/2] New UI --- .github/workflows/CI.yml | 2 + CLAUDE.md | 54 +++ src/display/mod.rs | 881 ++++++++++++++++++++++++++++++++++++++- src/main.rs | 2 - 4 files changed, 915 insertions(+), 24 deletions(-) create mode 100644 CLAUDE.md diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 39b8ee3..c380c26 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -20,3 +20,5 @@ jobs: run: cargo build --verbose - name: Run tests run: cargo test --verbose + - name: Clean environment + run: cargo clean diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..617c60a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OSC Parse is a Rust-based TUI (Terminal User Interface) tool for debugging OSC (Open Sound Control) messages on a network. It provides a simple way to analyze OSC traffic without requiring complex tools like Wireshark. + +## Build Commands + +```bash +# Build the project +cargo build + +# Build with verbose output +cargo build --verbose + +# Run the application +cargo run + +# Run tests +cargo test --verbose +``` + +## Architecture + +The codebase is organized into three modules: + +- **`src/main.rs`** - Entry point that initializes the TUI and starts the OSC listener on port 8000 +- **`src/display/mod.rs`** - TUI implementation using ratatui and crossterm for terminal rendering +- **`src/network/mod.rs`** - UDP socket listener that receives OSC packets and invokes callbacks +- **`src/osc/mod.rs`** - Manual OSC message parser handling addresses and data types (float, int, string) + +### Data Flow + +1. `main.rs` launches the TUI via `display::run_tui()` +2. Network listener (`network::listen_osc`) binds to UDP port 8000 +3. Received packets are passed to `osc::parse_message` for parsing +4. OSC address and typed values (f, i, s) are extracted following OSC protocol alignment rules + +## Testing + +A Python helper script exists at `test/send_osc_test.py` using `pythonosc` to send test messages to the running application. + +## Dependencies + +- **ratatui** - TUI framework +- **crossterm** - Terminal manipulation + +## Notes + +- OSC parsing is done manually without external OSC libraries +- All data is big-endian per OSC spec +- Buffer alignment follows 4-byte boundaries as required by OSC protocol diff --git a/src/display/mod.rs b/src/display/mod.rs index 6f99c18..748aefe 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -1,51 +1,888 @@ // MODULE POUR L'INTERFACE UTILISATEUR TUI -// Ce module utilise les crates 'crossterm' et 'ratatui' pour créer une interface utilisateur en mode texte. -// L'interface permet d'afficher des informations et d'interagir avec l'utilisateur via le terminal. - - - +// Interface moderne pour le debugging OSC avec ratatui use crossterm::{ - event::{self, Event, KeyCode}, + event::{self, Event, KeyCode, KeyModifiers}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; use ratatui::{ prelude::*, - widgets::Paragraph, + widgets::{Block, Borders, Paragraph, List, ListItem, ListState, Clear, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; use std::io::{self, stdout}; +use std::time::{Duration, Instant}; + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// STRUCTURES DE DONNÉES +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/// Représente une valeur OSC parsée +#[derive(Clone, Debug)] +pub enum OscValue { + Float(f32), + Int(i32), + String(String), + Unknown(String), +} + +impl OscValue { + pub fn to_display(&self) -> (String, Color) { + match self { + OscValue::Float(v) => (format!("{:.4}", v), Color::Cyan), + OscValue::Int(v) => (format!("{}", v), Color::Yellow), + OscValue::String(v) => (format!("\"{}\"", v), Color::Green), + OscValue::Unknown(v) => (format!("?{}", v), Color::Red), + } + } + + pub fn type_tag(&self) -> &str { + match self { + OscValue::Float(_) => "f", + OscValue::Int(_) => "i", + OscValue::String(_) => "s", + OscValue::Unknown(_) => "?", + } + } +} + +/// Message OSC complet avec timestamp +#[derive(Clone, Debug)] +pub struct OscMessage { + pub timestamp: String, + pub address: String, + pub values: Vec, + pub raw_size: usize, + pub source_addr: String, +} + +impl OscMessage { + pub fn format_compact(&self) -> String { + let values_str: Vec = self.values.iter() + .map(|v| v.to_display().0) + .collect(); + format!("{} {} [{}]", self.timestamp, self.address, values_str.join(", ")) + } + + pub fn format_types(&self) -> String { + self.values.iter().map(|v| v.type_tag()).collect::>().join("") + } +} + +/// Interface réseau disponible +#[derive(Clone, Debug)] +pub struct NetworkInterface { + pub name: String, + pub ip: String, + pub description: String, +} + +/// Mode actuel de l'interface +#[derive(Clone, PartialEq)] +pub enum UiMode { + Normal, + SelectInterface, + Help, +} + +/// État global de l'application +pub struct AppState { + // Messages OSC + pub messages: Vec, + pub messages_state: ListState, + pub max_messages: usize, + + // Interface réseau + pub interfaces: Vec, + pub selected_interface: usize, + pub interface_state: ListState, + + // Configuration + pub port: u16, + pub is_paused: bool, + pub is_running: bool, + + // UI + pub mode: UiMode, + pub scroll_offset: usize, + pub show_details: bool, + pub message_count: u64, + pub last_update: Instant, + + // Animation + pub animation_frame: usize, +} + +impl Default for AppState { + fn default() -> Self { + let mut interface_state = ListState::default(); + interface_state.select(Some(0)); + + Self { + messages: Vec::new(), + messages_state: ListState::default(), + max_messages: 1000, + + interfaces: vec![ + NetworkInterface { + name: "0.0.0.0".to_string(), + ip: "0.0.0.0".to_string(), + description: "Toutes les interfaces".to_string(), + }, + ], + selected_interface: 0, + interface_state, + + port: 8000, + is_paused: false, + is_running: true, + + mode: UiMode::Normal, + scroll_offset: 0, + show_details: true, + message_count: 0, + last_update: Instant::now(), + + animation_frame: 0, + } + } +} + +impl AppState { + /// Ajoute un nouveau message OSC + pub fn add_message(&mut self, msg: OscMessage) { + if !self.is_paused { + self.messages.push(msg); + self.message_count += 1; + + // Limiter le nombre de messages + if self.messages.len() > self.max_messages { + self.messages.remove(0); + } + + // Auto-scroll vers le dernier message + if !self.messages.is_empty() { + self.messages_state.select(Some(self.messages.len() - 1)); + } + + self.last_update = Instant::now(); + } + } + + /// Efface tous les messages + pub fn clear_messages(&mut self) { + self.messages.clear(); + self.messages_state.select(None); + self.scroll_offset = 0; + } + + /// Navigation dans la liste des messages + pub fn select_next(&mut self) { + if self.messages.is_empty() { + return; + } + let i = match self.messages_state.selected() { + Some(i) => { + if i >= self.messages.len() - 1 { + self.messages.len() - 1 + } else { + i + 1 + } + } + None => 0, + }; + self.messages_state.select(Some(i)); + } + + pub fn select_previous(&mut self) { + if self.messages.is_empty() { + return; + } + let i = match self.messages_state.selected() { + Some(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } + None => 0, + }; + self.messages_state.select(Some(i)); + } + + /// Navigation interface réseau + pub fn interface_next(&mut self) { + let i = match self.interface_state.selected() { + Some(i) => { + if i >= self.interfaces.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.interface_state.select(Some(i)); + } + + pub fn interface_previous(&mut self) { + let i = match self.interface_state.selected() { + Some(i) => { + if i == 0 { + self.interfaces.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.interface_state.select(Some(i)); + } + + /// Sélectionne l'interface courante + pub fn confirm_interface(&mut self) { + if let Some(i) = self.interface_state.selected() { + self.selected_interface = i; + } + self.mode = UiMode::Normal; + } + + /// Met à jour les interfaces disponibles (sera appelé par le module réseau) + pub fn set_interfaces(&mut self, interfaces: Vec) { + self.interfaces = interfaces; + if self.selected_interface >= self.interfaces.len() { + self.selected_interface = 0; + } + } + + /// Récupère l'interface sélectionnée + pub fn get_selected_interface(&self) -> Option<&NetworkInterface> { + self.interfaces.get(self.selected_interface) + } + + /// Récupère le message sélectionné + pub fn get_selected_message(&self) -> Option<&OscMessage> { + self.messages_state.selected().and_then(|i| self.messages.get(i)) + } + + /// Met à jour l'animation + pub fn tick(&mut self) { + self.animation_frame = (self.animation_frame + 1) % 4; + } +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// RENDU DE L'INTERFACE +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/// Dessine l'interface principale +fn draw_ui(frame: &mut Frame, state: &mut AppState) { + let area = frame.area(); + + // Layout principal: Header + Contenu + Footer + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header avec statut + Constraint::Min(10), // Contenu principal + Constraint::Length(3), // Barre de raccourcis + ]) + .split(area); + + // Dessiner le header + draw_header(frame, main_layout[0], state); + + // Contenu: Messages + Détails + let content_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(65), // Messages OSC + Constraint::Percentage(35), // Détails du message + ]) + .split(main_layout[1]); + + // Dessiner les messages OSC + draw_messages(frame, content_layout[0], state); + + // Dessiner les détails du message sélectionné + draw_details(frame, content_layout[1], state); + + // Dessiner la barre de raccourcis + draw_shortcuts(frame, main_layout[2], state); + + // Popups selon le mode + match state.mode { + UiMode::SelectInterface => draw_interface_popup(frame, state), + UiMode::Help => draw_help_popup(frame, state), + UiMode::Normal => {} + } +} + +/// Dessine le header avec le statut +fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) { + let animation_chars = ["◐", "◓", "◑", "◒"]; + let anim = if state.is_paused { "⏸" } else { animation_chars[state.animation_frame] }; + + let interface_name = state.get_selected_interface() + .map(|i| i.name.as_str()) + .unwrap_or("N/A"); + + let status = if state.is_paused { + "PAUSE".to_string() + } else { + format!("ÉCOUTE {}", anim) + }; + + let status_color = if state.is_paused { Color::Yellow } else { Color::Green }; + + let header_text = Line::from(vec![ + Span::styled(" OSC DEBUGGER ", Style::default().fg(Color::Black).bg(Color::Cyan).bold()), + Span::raw(" "), + Span::styled(format!(" {} ", status), Style::default().fg(Color::Black).bg(status_color).bold()), + Span::raw(" "), + Span::styled(format!("Port: {}", state.port), Style::default().fg(Color::White)), + Span::raw(" │ "), + Span::styled(format!("Interface: {}", interface_name), Style::default().fg(Color::White)), + Span::raw(" │ "), + Span::styled(format!("Messages: {}", state.message_count), Style::default().fg(Color::Cyan)), + ]); + + let header = Paragraph::new(header_text) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .border_type(ratatui::widgets::BorderType::Rounded)); + + frame.render_widget(header, area); +} + +/// Dessine la liste des messages OSC +fn draw_messages(frame: &mut Frame, area: Rect, state: &mut AppState) { + let messages: Vec = state.messages.iter().enumerate().map(|(idx, msg)| { + let is_selected = state.messages_state.selected() == Some(idx); + + // Créer les spans avec couleurs selon les types + let mut spans = vec![ + Span::styled( + format!("{} ", msg.timestamp), + Style::default().fg(Color::DarkGray) + ), + Span::styled( + format!("{} ", msg.address), + Style::default().fg(Color::Magenta).bold() + ), + ]; + + // Ajouter les valeurs avec leurs couleurs + spans.push(Span::raw("[")); + for (i, val) in msg.values.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(", ")); + } + let (text, color) = val.to_display(); + spans.push(Span::styled(text, Style::default().fg(color))); + } + spans.push(Span::raw("]")); + + let style = if is_selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + ListItem::new(Line::from(spans)).style(style) + }).collect(); + + let title = format!(" Messages OSC ({}) ", state.messages.len()); + + let messages_list = List::new(messages) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Blue)) + .border_type(ratatui::widgets::BorderType::Rounded) + .title(title) + .title_style(Style::default().fg(Color::Blue).bold())) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("▶ "); + + frame.render_stateful_widget(messages_list, area, &mut state.messages_state); + + // Scrollbar + if !state.messages.is_empty() { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + + let mut scrollbar_state = ScrollbarState::new(state.messages.len()) + .position(state.messages_state.selected().unwrap_or(0)); + + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { vertical: 1, horizontal: 0 }), + &mut scrollbar_state, + ); + } +} + +/// Dessine les détails du message sélectionné +fn draw_details(frame: &mut Frame, area: Rect, state: &AppState) { + let content = if let Some(msg) = state.get_selected_message() { + let mut lines = vec![ + Line::from(vec![ + Span::styled("Adresse: ", Style::default().fg(Color::Gray)), + Span::styled(&msg.address, Style::default().fg(Color::Magenta).bold().underlined()), + ]), + Line::from(vec![ + Span::styled("Types: ", Style::default().fg(Color::Gray)), + Span::styled(format!(",{}", msg.format_types()), Style::default().fg(Color::Yellow)), + ]), + Line::from(vec![ + Span::styled("Source: ", Style::default().fg(Color::Gray)), + Span::styled(&msg.source_addr, Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled("Taille: ", Style::default().fg(Color::Gray)), + Span::styled(format!("{} octets", msg.raw_size), Style::default().fg(Color::White)), + ]), + Line::from(""), + Line::from(Span::styled("─── Valeurs ───", Style::default().fg(Color::DarkGray))), + ]; + + // Afficher chaque valeur en grand + for (i, val) in msg.values.iter().enumerate() { + let (text, color) = val.to_display(); + lines.push(Line::from(vec![ + Span::styled(format!(" [{}] ", i), Style::default().fg(Color::DarkGray)), + Span::styled(val.type_tag(), Style::default().fg(Color::Yellow)), + Span::raw(": "), + Span::styled(text, Style::default().fg(color).bold()), + ])); + } + + lines + } else { + vec![ + Line::from(""), + Line::from(Span::styled( + " Aucun message sélectionné", + Style::default().fg(Color::DarkGray).italic() + )), + Line::from(""), + Line::from(Span::styled( + " Utilisez ↑/↓ pour naviguer dans les messages", + Style::default().fg(Color::DarkGray) + )), + ] + }; + + let details = Paragraph::new(content) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .border_type(ratatui::widgets::BorderType::Rounded) + .title(" Détails ") + .title_style(Style::default().fg(Color::Magenta).bold())); + + frame.render_widget(details, area); +} + +/// Dessine la barre de raccourcis +fn draw_shortcuts(frame: &mut Frame, area: Rect, state: &AppState) { + let shortcuts = vec![ + ("Q", "Quitter", Color::Red), + ("C", "Clear", Color::Yellow), + ("D", "Interface", Color::Cyan), + ("Espace", if state.is_paused { "Reprendre" } else { "Pause" }, Color::Green), + ("↑↓", "Naviguer", Color::Blue), + ("?", "Aide", Color::Magenta), + ]; + + let mut spans: Vec = vec![Span::raw(" ")]; + for (i, (key, desc, color)) in shortcuts.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray))); + } + spans.push(Span::styled( + format!(" {} ", key), + Style::default().fg(Color::Black).bg(*color).bold() + )); + spans.push(Span::styled( + format!(" {} ", desc), + Style::default().fg(Color::White) + )); + } + + let shortcuts_bar = Paragraph::new(Line::from(spans)) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .border_type(ratatui::widgets::BorderType::Rounded)); + + frame.render_widget(shortcuts_bar, area); +} +/// Dessine le popup de sélection d'interface +fn draw_interface_popup(frame: &mut Frame, state: &mut AppState) { + let area = centered_rect(60, 50, frame.area()); + + // Clear background + frame.render_widget(Clear, area); + + let interfaces: Vec = state.interfaces.iter().enumerate().map(|(idx, iface)| { + let is_current = idx == state.selected_interface; + let marker = if is_current { "●" } else { "○" }; + + let line = Line::from(vec![ + Span::styled( + format!(" {} ", marker), + Style::default().fg(if is_current { Color::Green } else { Color::DarkGray }) + ), + Span::styled( + format!("{} ", iface.name), + Style::default().fg(Color::White).bold() + ), + Span::styled( + format!("({})", iface.ip), + Style::default().fg(Color::Cyan) + ), + ]); + + ListItem::new(line) + }).collect(); + + let popup = List::new(interfaces) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .border_type(ratatui::widgets::BorderType::Double) + .title(" Sélectionner l'interface réseau ") + .title_style(Style::default().fg(Color::Cyan).bold()) + .title_bottom(" ↑↓ Naviguer │ Entrée Confirmer │ Échap Annuler ")) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("▶ "); + + frame.render_stateful_widget(popup, area, &mut state.interface_state); +} + +/// Dessine le popup d'aide +fn draw_help_popup(frame: &mut Frame, _state: &AppState) { + let area = centered_rect(70, 70, frame.area()); + + frame.render_widget(Clear, area); + + let help_text = vec![ + Line::from(""), + Line::from(Span::styled(" OSC PARSE - Debugger OSC", Style::default().fg(Color::Cyan).bold())), + Line::from(""), + Line::from(Span::styled(" ─── Raccourcis clavier ───", Style::default().fg(Color::DarkGray))), + Line::from(""), + Line::from(vec![ + Span::styled(" Q ", Style::default().fg(Color::Yellow)), + Span::raw("Quitter l'application"), + ]), + Line::from(vec![ + Span::styled(" C ", Style::default().fg(Color::Yellow)), + Span::raw("Effacer tous les messages"), + ]), + Line::from(vec![ + Span::styled(" D ", Style::default().fg(Color::Yellow)), + Span::raw("Sélectionner l'interface réseau"), + ]), + Line::from(vec![ + Span::styled(" Espace ", Style::default().fg(Color::Yellow)), + Span::raw("Pause/Reprendre la capture"), + ]), + Line::from(vec![ + Span::styled(" ↑ / ↓ ", Style::default().fg(Color::Yellow)), + Span::raw("Naviguer dans les messages"), + ]), + Line::from(vec![ + Span::styled(" ? ", Style::default().fg(Color::Yellow)), + Span::raw("Afficher cette aide"), + ]), + Line::from(""), + Line::from(Span::styled(" ─── Types OSC ───", Style::default().fg(Color::DarkGray))), + Line::from(""), + Line::from(vec![ + Span::styled(" f ", Style::default().fg(Color::Cyan)), + Span::raw("Float (nombre décimal)"), + ]), + Line::from(vec![ + Span::styled(" i ", Style::default().fg(Color::Yellow)), + Span::raw("Integer (nombre entier)"), + ]), + Line::from(vec![ + Span::styled(" s ", Style::default().fg(Color::Green)), + Span::raw("String (chaîne de caractères)"), + ]), + Line::from(""), + Line::from(Span::styled(" ─── Informations ───", Style::default().fg(Color::DarkGray))), + Line::from(""), + Line::from(vec![ + Span::raw(" Port UDP: "), + Span::styled("8000", Style::default().fg(Color::Cyan)), + ]), + Line::from(""), + ]; + + let popup = Paragraph::new(help_text) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .border_type(ratatui::widgets::BorderType::Double) + .title(" Aide ") + .title_style(Style::default().fg(Color::Magenta).bold()) + .title_bottom(" Appuyez sur une touche pour fermer ")); + + frame.render_widget(popup, area); +} + +/// Crée un rectangle centré +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// BOUCLE PRINCIPALE +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/// Lance l'interface TUI +/// Retourne l'état de l'application pour permettre l'intégration avec le module réseau pub fn run_tui() -> io::Result<()> { - // 1. Activer le mode raw + // Initialisation du terminal enable_raw_mode()?; - - // 2. Passer en écran alternatif stdout().execute(EnterAlternateScreen)?; - // 3. Créer le terminal let backend = CrosstermBackend::new(stdout()); let mut terminal = Terminal::new(backend)?; - //4. Boucle principale de l'interface utilisateur + // État de l'application + let mut state = AppState::default(); + + // Timer pour l'animation + let tick_rate = Duration::from_millis(250); + let mut last_tick = Instant::now(); + + // Boucle principale loop { - // Dessiner l'interface utilisateur + // Dessiner l'interface terminal.draw(|frame| { - let text = Paragraph::new("OSC Snippet Parser TUI | Press 'q' to quit"); - frame.render_widget(text, frame.size()); + draw_ui(frame, &mut state); })?; - // Attendre les événements utilisateur - if let Event::Key(key) = event::read()? { - if key.code == KeyCode::Char('q') { - break; // Quitter la boucle si l'utilisateur appuie sur 'q' + // Gestion des événements avec timeout + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + match state.mode { + UiMode::Normal => { + handle_normal_mode(&mut state, key.code, key.modifiers); + } + UiMode::SelectInterface => { + handle_interface_mode(&mut state, key.code); + } + UiMode::Help => { + // N'importe quelle touche ferme l'aide + state.mode = UiMode::Normal; + } + } + + // Quitter si demandé + if !state.is_running { + break; + } + } + } + + // Mise à jour de l'animation + if last_tick.elapsed() >= tick_rate { + state.tick(); + last_tick = Instant::now(); + } + } + + // Restauration du terminal + cleanup_terminal()?; + Ok(()) +} + +/// Gère les événements en mode normal +fn handle_normal_mode(state: &mut AppState, key: KeyCode, _modifiers: KeyModifiers) { + match key { + KeyCode::Char('q') | KeyCode::Char('Q') => { + state.is_running = false; + } + KeyCode::Char('c') | KeyCode::Char('C') => { + state.clear_messages(); + } + KeyCode::Char('d') | KeyCode::Char('D') => { + state.mode = UiMode::SelectInterface; + state.interface_state.select(Some(state.selected_interface)); + } + KeyCode::Char(' ') => { + state.is_paused = !state.is_paused; + } + KeyCode::Char('?') => { + state.mode = UiMode::Help; + } + KeyCode::Up => { + state.select_previous(); + } + KeyCode::Down => { + state.select_next(); + } + KeyCode::Home => { + if !state.messages.is_empty() { + state.messages_state.select(Some(0)); + } + } + KeyCode::End => { + if !state.messages.is_empty() { + state.messages_state.select(Some(state.messages.len() - 1)); + } + } + KeyCode::PageUp => { + for _ in 0..10 { + state.select_previous(); } } + KeyCode::PageDown => { + for _ in 0..10 { + state.select_next(); + } + } + _ => {} + } +} + +/// Gère les événements en mode sélection d'interface +fn handle_interface_mode(state: &mut AppState, key: KeyCode) { + match key { + KeyCode::Up => { + state.interface_previous(); + } + KeyCode::Down => { + state.interface_next(); + } + KeyCode::Enter => { + state.confirm_interface(); + } + KeyCode::Esc => { + state.mode = UiMode::Normal; + } + _ => {} } +} - // 5. Restaurer l'états initial du terminal +/// Nettoie et restaure le terminal +fn cleanup_terminal() -> io::Result<()> { disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?; - Ok(()) -} \ No newline at end of file +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// API PUBLIQUE POUR L'INTÉGRATION RÉSEAU +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/// Crée un timestamp formaté pour les messages +pub fn create_timestamp() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = now.as_secs() % 86400; // Secondes dans la journée + let hours = (secs / 3600) % 24; + let mins = (secs % 3600) / 60; + let secs = secs % 60; + let millis = now.subsec_millis(); + format!("{:02}:{:02}:{:02}.{:03}", hours, mins, secs, millis) +} + +/// Parse les valeurs OSC depuis un buffer (pour intégration avec le module osc) +/// Cette fonction peut être utilisée par le module réseau pour créer des OscMessage +pub fn parse_osc_values(buf: &[u8], taille: usize) -> Option<(String, Vec)> { + let mut pos = 0; + + // Extraire l'adresse + while pos < taille && buf[pos] != 0 { + pos += 1; + } + let address = std::str::from_utf8(&buf[0..pos]).ok()?.to_string(); + + // Aligner sur 4 octets + while pos % 4 != 0 { + pos += 1; + } + + let mut values = Vec::new(); + + // Parser les types + if pos < taille && buf[pos] == b',' { + let type_start = pos; + while pos < taille && buf[pos] != 0 { + pos += 1; + } + + let type_tags = std::str::from_utf8(&buf[type_start + 1..pos]).ok()?; + + // Aligner + while pos % 4 != 0 { + pos += 1; + } + + // Parser les valeurs + for type_char in type_tags.chars() { + match type_char { + 'f' => { + if pos + 4 <= taille { + let bytes = [buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]; + values.push(OscValue::Float(f32::from_be_bytes(bytes))); + pos += 4; + } + } + 'i' => { + if pos + 4 <= taille { + let bytes = [buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]; + values.push(OscValue::Int(i32::from_be_bytes(bytes))); + pos += 4; + } + } + 's' => { + let str_start = pos; + while pos < taille && buf[pos] != 0 { + pos += 1; + } + if let Ok(s) = std::str::from_utf8(&buf[str_start..pos]) { + values.push(OscValue::String(s.to_string())); + } + while pos % 4 != 0 { + pos += 1; + } + } + _ => { + values.push(OscValue::Unknown(type_char.to_string())); + } + } + } + } + + Some((address, values)) +} diff --git a/src/main.rs b/src/main.rs index 59f57cf..7b52335 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,6 @@ mod display; // Importer le module 'display' défini dans src/display/mod.rs // Fonction principale du programme fn main() { - println!("OSC Snippet Parser booting up...\n"); // Afficher un message de démarrage dans la console - // Lancement de l'interface utilisateur en mode texte (TUI) if let Err(e) = display::run_tui() { eprintln!("Error running TUI : {}", e); // Afficher une erreur si l'inteface utilisateur ne peut pas se lancer de maniére correct.