use crate::audio::EngineController; use crate::command::AudioEvent; use crate::io::load_midi_file; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Paragraph}, Frame, Terminal, }; use std::io; use std::sync::{Arc, Mutex}; use std::time::Duration; /// TUI application mode #[derive(Debug, Clone, Copy, PartialEq)] pub enum AppMode { /// Command mode - type vim-style commands Command, /// Play mode - use keyboard to play MIDI notes Play, } /// TUI application state pub struct TuiApp { /// Current application mode mode: AppMode, /// Command input buffer (for Command mode) command_input: String, /// Current playback position (seconds) playback_position: f64, /// Whether playback is active is_playing: bool, /// Status message to display status_message: String, /// List of tracks (track_id, name) tracks: Vec<(u32, String)>, /// Currently selected track for MIDI input selected_track: Option, /// Active MIDI notes (currently held down) active_notes: Vec, /// Command history for up/down navigation command_history: Vec, /// Current position in command history history_index: Option, /// Clips on timeline: (track_id, clip_id, start_time, duration, name, notes) /// Notes: Vec<(pitch, time_offset, duration)> clips: Vec<(u32, u32, f64, f64, String, Vec<(u8, f64, f64)>)>, /// Next clip ID for locally created clips next_clip_id: u32, /// Timeline scroll offset in seconds (start of visible window) timeline_scroll: f64, /// Timeline visible duration in seconds (zoom level) timeline_visible_duration: f64, } impl TuiApp { pub fn new() -> Self { Self { mode: AppMode::Command, command_input: String::new(), playback_position: 0.0, is_playing: false, status_message: "SPACE=play/pause | ←/→ scroll | -/+ zoom | 'i'=Play mode | Type 'help'".to_string(), tracks: Vec::new(), selected_track: None, active_notes: Vec::new(), command_history: Vec::new(), history_index: None, clips: Vec::new(), next_clip_id: 0, timeline_scroll: 0.0, timeline_visible_duration: 10.0, // Show 10 seconds at a time by default } } /// Switch to command mode pub fn enter_command_mode(&mut self) { self.mode = AppMode::Command; self.command_input.clear(); self.history_index = None; self.status_message = "-- COMMAND -- SPACE=play/pause | ←/→ scroll | -/+ zoom | 'i' for Play mode | Type 'help'".to_string(); } /// Switch to play mode pub fn enter_play_mode(&mut self) { self.mode = AppMode::Play; self.command_input.clear(); self.status_message = "-- PLAY -- Press '?' for help, 'ESC' for Command mode".to_string(); } /// Add a character to command input pub fn push_command_char(&mut self, c: char) { self.command_input.push(c); } /// Remove last character from command input pub fn pop_command_char(&mut self) { self.command_input.pop(); } /// Get the current command input pub fn command_input(&self) -> &str { &self.command_input } /// Clear command input pub fn clear_command(&mut self) { self.command_input.clear(); self.history_index = None; } /// Add command to history pub fn add_to_history(&mut self, command: String) { if !command.is_empty() && self.command_history.last() != Some(&command) { self.command_history.push(command); } } /// Navigate command history up pub fn history_up(&mut self) { if self.command_history.is_empty() { return; } let new_index = match self.history_index { None => Some(self.command_history.len() - 1), Some(0) => Some(0), Some(i) => Some(i - 1), }; if let Some(idx) = new_index { self.history_index = Some(idx); self.command_input = self.command_history[idx].clone(); } } /// Navigate command history down pub fn history_down(&mut self) { match self.history_index { None => {} Some(i) if i >= self.command_history.len() - 1 => { self.history_index = None; self.command_input.clear(); } Some(i) => { let new_idx = i + 1; self.history_index = Some(new_idx); self.command_input = self.command_history[new_idx].clone(); } } } /// Update playback position and auto-scroll timeline if needed pub fn update_playback_position(&mut self, position: f64) { self.playback_position = position; // Auto-scroll to keep playhead in view when playing if self.is_playing { // Keep playhead in the visible window, with some margin let margin = self.timeline_visible_duration * 0.1; // 10% margin // If playhead is ahead of visible window, scroll forward if position > self.timeline_scroll + self.timeline_visible_duration - margin { self.timeline_scroll = (position - self.timeline_visible_duration * 0.5).max(0.0); } // If playhead is behind visible window, scroll backward else if position < self.timeline_scroll + margin { self.timeline_scroll = (position - margin).max(0.0); } } } /// Set playing state pub fn set_playing(&mut self, playing: bool) { self.is_playing = playing; } /// Set status message pub fn set_status(&mut self, message: String) { self.status_message = message; } /// Add a track to the UI pub fn add_track(&mut self, track_id: u32, name: String) { self.tracks.push((track_id, name)); // Auto-select first MIDI track for playing if self.selected_track.is_none() { self.selected_track = Some(track_id); } } /// Clear all tracks pub fn clear_tracks(&mut self) { self.tracks.clear(); self.clips.clear(); self.selected_track = None; self.next_clip_id = 0; self.timeline_scroll = 0.0; } /// Select a track by index pub fn select_track(&mut self, index: usize) { if let Some((track_id, _)) = self.tracks.get(index) { self.selected_track = Some(*track_id); } } /// Get selected track pub fn selected_track(&self) -> Option { self.selected_track } /// Add a clip to the timeline pub fn add_clip(&mut self, track_id: u32, clip_id: u32, start_time: f64, duration: f64, name: String, notes: Vec<(u8, f64, f64)>) { self.clips.push((track_id, clip_id, start_time, duration, name, notes)); } /// Get max timeline duration based on clips pub fn get_timeline_duration(&self) -> f64 { self.clips .iter() .map(|(_, _, start, dur, _, _)| start + dur) .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap_or(10.0) // Default to 10 seconds if no clips } /// Add an active MIDI note pub fn add_active_note(&mut self, note: u8) { if !self.active_notes.contains(¬e) { self.active_notes.push(note); } } /// Remove an active MIDI note pub fn remove_active_note(&mut self, note: u8) { self.active_notes.retain(|&n| n != note); } /// Get current mode pub fn mode(&self) -> AppMode { self.mode } /// Scroll timeline left pub fn scroll_timeline_left(&mut self) { let scroll_amount = self.timeline_visible_duration * 0.2; // Scroll by 20% of visible duration self.timeline_scroll = (self.timeline_scroll - scroll_amount).max(0.0); } /// Scroll timeline right pub fn scroll_timeline_right(&mut self) { let scroll_amount = self.timeline_visible_duration * 0.2; // Scroll by 20% of visible duration let max_duration = self.get_timeline_duration(); self.timeline_scroll = (self.timeline_scroll + scroll_amount).min(max_duration - self.timeline_visible_duration).max(0.0); } /// Zoom timeline in (show less time, more detail) pub fn zoom_timeline_in(&mut self) { self.timeline_visible_duration = (self.timeline_visible_duration * 0.8).max(1.0); // Min 1 second visible } /// Zoom timeline out (show more time, less detail) pub fn zoom_timeline_out(&mut self) { let max_duration = self.get_timeline_duration(); self.timeline_visible_duration = (self.timeline_visible_duration * 1.25).min(max_duration).max(1.0); } } /// Map keyboard keys to MIDI notes /// Uses chromatic layout: awsedftgyhujkolp;' /// This provides 1.5 octaves starting from C4 (MIDI note 60) pub fn key_to_midi_note(key: KeyCode) -> Option { let base = 60; // C4 match key { KeyCode::Char('a') => Some(base), // C4 KeyCode::Char('w') => Some(base + 1), // C#4 KeyCode::Char('s') => Some(base + 2), // D4 KeyCode::Char('e') => Some(base + 3), // D#4 KeyCode::Char('d') => Some(base + 4), // E4 KeyCode::Char('f') => Some(base + 5), // F4 KeyCode::Char('t') => Some(base + 6), // F#4 KeyCode::Char('g') => Some(base + 7), // G4 KeyCode::Char('y') => Some(base + 8), // G#4 KeyCode::Char('h') => Some(base + 9), // A4 KeyCode::Char('u') => Some(base + 10), // A#4 KeyCode::Char('j') => Some(base + 11), // B4 KeyCode::Char('k') => Some(base + 12), // C5 KeyCode::Char('o') => Some(base + 13), // C#5 KeyCode::Char('l') => Some(base + 14), // D5 KeyCode::Char('p') => Some(base + 15), // D#5 KeyCode::Char(';') => Some(base + 16), // E5 KeyCode::Char('\'') => Some(base + 17), // F5 _ => None, } } /// Convert pitch % 8 to braille dot bit position fn pitch_to_braille_bit(pitch_mod_8: u8) -> u8 { match pitch_mod_8 { 0 => 0x01, // Dot 1 1 => 0x02, // Dot 2 2 => 0x04, // Dot 3 3 => 0x40, // Dot 7 4 => 0x08, // Dot 4 5 => 0x10, // Dot 5 6 => 0x20, // Dot 6 7 => 0x80, // Dot 8 _ => 0x00, } } /// Draw the timeline view with clips fn draw_timeline(f: &mut Frame, area: ratatui::layout::Rect, app: &TuiApp) { let num_tracks = app.tracks.len(); // Use visible duration for the timeline window let visible_start = app.timeline_scroll; let visible_end = app.timeline_scroll + app.timeline_visible_duration; // Create the timeline block with visible range let block = Block::default() .borders(Borders::ALL) .title(format!("Timeline ({:.1}s - {:.1}s) | ←/→ scroll | -/+ zoom", visible_start, visible_end)); let inner_area = block.inner(area); f.render_widget(block, area); // Calculate dimensions let width = inner_area.width as usize; if width == 0 || num_tracks == 0 { return; } // Fixed track height: 2 lines per track let track_height = 2; // Build timeline content with braille characters let mut lines: Vec = Vec::new(); for track_idx in 0..num_tracks { let track_id = if let Some((id, _)) = app.tracks.get(track_idx) { *id } else { continue; }; // Create exactly 2 lines for this track for _ in 0..track_height { let mut spans = Vec::new(); // Build the timeline character by character for char_x in 0..width { // Map character position to time, using scroll offset let time_pos = visible_start + (char_x as f64 / width as f64) * app.timeline_visible_duration; // Check if playhead is at this position let is_playhead = (time_pos - app.playback_position).abs() < (app.timeline_visible_duration / width as f64); // Find all notes active at this time position on this track let mut braille_pattern: u8 = 0; let mut has_notes = false; for (clip_track_id, _clip_id, clip_start, _clip_duration, _name, notes) in &app.clips { if *clip_track_id == track_id { // Check each note in this clip for (pitch, note_offset, note_duration) in notes { let note_start = clip_start + note_offset; let note_end = note_start + note_duration; // Is this note active at current time position? if time_pos >= note_start && time_pos < note_end { let pitch_mod = pitch % 8; braille_pattern |= pitch_to_braille_bit(pitch_mod); has_notes = true; } } } } // Determine color let color = if Some(track_id) == app.selected_track { Color::Yellow } else { Color::Cyan }; // Create span if is_playhead { // Playhead: red background if has_notes { // Show white notes with red background let braille_char = char::from_u32(0x2800 + braille_pattern as u32).unwrap_or(' '); spans.push(Span::styled(braille_char.to_string(), Style::default().fg(Color::White).bg(Color::Red))); } else { spans.push(Span::styled(" ", Style::default().bg(Color::Red))); } } else if has_notes { // Show white braille pattern on colored background let braille_char = char::from_u32(0x2800 + braille_pattern as u32).unwrap_or(' '); spans.push(Span::styled(braille_char.to_string(), Style::default().fg(Color::White).bg(color))); } else { // Empty space spans.push(Span::raw(" ")); } } lines.push(Line::from(spans)); } } let paragraph = Paragraph::new(lines); f.render_widget(paragraph, inner_area); } /// Draw the TUI pub fn draw_ui(f: &mut Frame, app: &TuiApp) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Title bar Constraint::Min(10), // Main content Constraint::Length(3), // Status bar Constraint::Length(1), // Command line ]) .split(f.size()); // Title bar let title = Paragraph::new("Lightningbeam DAW") .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .block(Block::default().borders(Borders::ALL)); f.render_widget(title, chunks[0]); // Main content area - split into tracks and timeline let content_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) .split(chunks[1]); // Tracks list - each track gets 2 lines to match timeline let track_items: Vec = app .tracks .iter() .map(|(id, name)| { let style = if app.selected_track == Some(*id) { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default() }; // Create a 2-line item: track info on first line, empty second line let lines = vec![ Line::from(format!("T{}: {}", id, name)), Line::from(""), ]; ListItem::new(lines).style(style) }) .collect(); let tracks_list = List::new(track_items) .block(Block::default().borders(Borders::ALL).title("Tracks")); f.render_widget(tracks_list, content_chunks[0]); // Timeline area - split vertically into playback info and timeline view let timeline_chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(4), Constraint::Min(5)]) .split(content_chunks[1]); // Playback info let playback_info = vec![ Line::from(vec![ Span::raw("Position: "), Span::styled( format!("{:.2}s", app.playback_position), Style::default().fg(Color::Green), ), Span::raw(" | Status: "), Span::styled( if app.is_playing { "Playing" } else { "Stopped" }, if app.is_playing { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Red) }, ), ]), Line::from(format!("Active Notes: {}", app.active_notes .iter() .map(|n| format!("{} ", n)) .collect::() )), ]; let info = Paragraph::new(playback_info) .block(Block::default().borders(Borders::ALL).title("Playback")); f.render_widget(info, timeline_chunks[0]); // Draw timeline draw_timeline(f, timeline_chunks[1], app); // Status bar let mode_indicator = match app.mode { AppMode::Command => "COMMAND", AppMode::Play => "PLAY", }; let status_text = format!("Mode: {} | {}", mode_indicator, app.status_message); let status_bar = Paragraph::new(status_text) .style(Style::default().fg(Color::White)) .block(Block::default().borders(Borders::ALL)); f.render_widget(status_bar, chunks[2]); // Command line let command_line = if app.mode == AppMode::Command { format!(":{}", app.command_input) } else { String::from("ESC=cmd mode | awsedftgyhujkolp;'=notes | R=release notes | ?=help | SPACE=play/pause") }; let cmd_widget = Paragraph::new(command_line).style(Style::default().fg(Color::Yellow)); f.render_widget(cmd_widget, chunks[3]); } /// Run the TUI application pub fn run_tui( mut controller: EngineController, event_rx: Arc>>, ) -> Result<(), Box> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // Create app state let mut app = TuiApp::new(); // Main loop loop { // Draw UI terminal.draw(|f| draw_ui(f, &app))?; // Poll for audio events if let Ok(mut rx) = event_rx.lock() { while let Ok(event) = rx.pop() { match event { AudioEvent::PlaybackPosition(pos) => { app.update_playback_position(pos); } AudioEvent::PlaybackStopped => { app.set_playing(false); } AudioEvent::TrackCreated(track_id, _, name) => { app.add_track(track_id, name); } AudioEvent::RecordingStopped(clip_id, _pool_index, _waveform) => { // Update status app.set_status(format!("Recording stopped - Clip {}", clip_id)); } AudioEvent::ProjectReset => { app.clear_tracks(); app.set_status("Project reset".to_string()); } _ => {} } } } // Handle keyboard input with timeout if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { match app.mode() { AppMode::Command => { match key.code { KeyCode::Left => { // Scroll timeline left only if command buffer is empty if app.command_input().is_empty() { app.scroll_timeline_left(); } } KeyCode::Right => { // Scroll timeline right only if command buffer is empty if app.command_input().is_empty() { app.scroll_timeline_right(); } } KeyCode::Char('-') | KeyCode::Char('_') => { // Zoom out only if command buffer is empty if app.command_input().is_empty() { app.zoom_timeline_out(); } } KeyCode::Char('+') | KeyCode::Char('=') => { // Zoom in only if command buffer is empty if app.command_input().is_empty() { app.zoom_timeline_in(); } } KeyCode::Char(' ') => { // Spacebar toggles play/pause only if command buffer is empty // Otherwise, add space to command if app.command_input().is_empty() { if app.is_playing { controller.pause(); app.set_playing(false); app.set_status("Paused".to_string()); } else { controller.play(); app.set_playing(true); app.set_status("Playing".to_string()); } } else { app.push_command_char(' '); } } KeyCode::Esc => { app.clear_command(); } KeyCode::Enter => { let command = app.command_input().to_string(); app.add_to_history(command.clone()); // Execute command match execute_command(&command, &mut controller, &mut app) { Err(e) if e == "Quit requested" => { break; // Exit the application } Err(e) => { app.set_status(format!("Error: {}", e)); } Ok(_) => {} } app.clear_command(); } KeyCode::Backspace => { app.pop_command_char(); } KeyCode::Up => { app.history_up(); } KeyCode::Down => { app.history_down(); } KeyCode::Char('i') => { // Only switch to Play mode if command buffer is empty if app.command_input().is_empty() { app.enter_play_mode(); } else { app.push_command_char('i'); } } KeyCode::Char(c) => { app.push_command_char(c); } _ => {} } } AppMode::Play => { // Check for mode switch first if key.code == KeyCode::Esc { app.enter_command_mode(); continue; } // Check for quit if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) { break; } // Handle MIDI note playing if let Some(note) = key_to_midi_note(key.code) { if let Some(track_id) = app.selected_track() { // Release all previous notes before playing new one for prev_note in app.active_notes.clone() { controller.send_midi_note_off(track_id, prev_note); } app.active_notes.clear(); // Play the new note controller.send_midi_note_on(track_id, note, 100); app.add_active_note(note); } } else { // Handle other play mode shortcuts match key.code { KeyCode::Char(' ') => { // Release all notes and toggle play/pause if let Some(track_id) = app.selected_track() { for note in app.active_notes.clone() { controller.send_midi_note_off(track_id, note); } app.active_notes.clear(); } if app.is_playing { controller.pause(); app.set_playing(false); } else { controller.play(); app.set_playing(true); } } KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Release all notes and stop if let Some(track_id) = app.selected_track() { for note in app.active_notes.clone() { controller.send_midi_note_off(track_id, note); } app.active_notes.clear(); } controller.stop(); app.set_playing(false); } KeyCode::Char('r') | KeyCode::Char('R') => { // Release all notes manually (r for release) if let Some(track_id) = app.selected_track() { for note in app.active_notes.clone() { controller.send_midi_note_off(track_id, note); } app.active_notes.clear(); } app.set_status("All notes released".to_string()); } KeyCode::Char('?') | KeyCode::Char('h') | KeyCode::Char('H') => { app.set_status("Play Mode: awsedftgyhujkolp;'=notes | R=release | SPACE=play/pause | ESC=command | Ctrl+Q=quit".to_string()); } _ => {} } } } } } } } // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; Ok(()) } /// Execute a command string fn execute_command( command: &str, controller: &mut EngineController, app: &mut TuiApp, ) -> Result<(), String> { let parts: Vec<&str> = command.trim().split_whitespace().collect(); if parts.is_empty() { return Ok(()); } match parts[0] { "play" => { controller.play(); app.set_playing(true); app.set_status("Playing".to_string()); } "pause" => { controller.pause(); app.set_playing(false); app.set_status("Paused".to_string()); } "stop" => { controller.stop(); app.set_playing(false); app.set_status("Stopped".to_string()); } "seek" => { if parts.len() < 2 { return Err("Usage: seek ".to_string()); } let pos: f64 = parts[1].parse().map_err(|_| "Invalid position")?; controller.seek(pos); app.set_status(format!("Seeked to {:.2}s", pos)); } "track" => { if parts.len() < 2 { return Err("Usage: track ".to_string()); } let name = parts[1..].join(" "); controller.create_midi_track(name.clone()); app.set_status(format!("Created MIDI track: {}", name)); } "audiotrack" => { if parts.len() < 2 { return Err("Usage: audiotrack ".to_string()); } let name = parts[1..].join(" "); controller.create_audio_track(name.clone()); app.set_status(format!("Created audio track: {}", name)); } "select" => { if parts.len() < 2 { return Err("Usage: select ".to_string()); } let idx: usize = parts[1].parse().map_err(|_| "Invalid track number")?; app.select_track(idx); app.set_status(format!("Selected track {}", idx)); } "clip" => { if parts.len() < 4 { return Err("Usage: clip ".to_string()); } let track_id: u32 = parts[1].parse().map_err(|_| "Invalid track ID")?; let start_time: f64 = parts[2].parse().map_err(|_| "Invalid start time")?; let duration: f64 = parts[3].parse().map_err(|_| "Invalid duration")?; // Add clip to local UI state (empty clip, no notes) let clip_id = app.next_clip_id; app.next_clip_id += 1; app.add_clip(track_id, clip_id, start_time, duration, format!("Clip {}", clip_id), Vec::new()); controller.create_midi_clip(track_id, start_time, duration); app.set_status(format!("Created MIDI clip on track {} at {:.2}s for {:.2}s", track_id, start_time, duration)); } "loadmidi" => { if parts.len() < 3 { return Err("Usage: loadmidi [start_time]".to_string()); } let track_id: u32 = parts[1].parse().map_err(|_| "Invalid track ID")?; let file_path = parts[2]; let start_time: f64 = if parts.len() >= 4 { parts[3].parse().unwrap_or(0.0) } else { 0.0 }; // Load the MIDI file match load_midi_file(file_path, app.next_clip_id, 48000) { Ok(midi_clip) => { let clip_id = midi_clip.id; let duration = midi_clip.duration; let event_count = midi_clip.events.len(); // Extract note data for visualization let mut notes = Vec::new(); let mut active_notes: std::collections::HashMap = std::collections::HashMap::new(); let sample_rate = 48000.0; // Sample rate used for loading MIDI for event in &midi_clip.events { let status = event.status & 0xF0; let time_seconds = event.timestamp as f64 / sample_rate; match status { 0x90 if event.data2 > 0 => { // Note on active_notes.insert(event.data1, time_seconds); } 0x80 | 0x90 => { // Note off (or note on with velocity 0) if let Some(start) = active_notes.remove(&event.data1) { let note_duration = time_seconds - start; notes.push((event.data1, start, note_duration)); } } _ => {} } } // Add to local UI state with note data app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes); app.next_clip_id += 1; // Send to audio engine with the start_time (clip content is separate from timeline position) controller.add_loaded_midi_clip(track_id, midi_clip, start_time); app.set_status(format!("Loaded {} ({} events, {:.2}s) to track {} at {:.2}s", file_path, event_count, duration, track_id, start_time)); } Err(e) => { return Err(format!("Failed to load MIDI file: {}", e)); } } } "reset" => { controller.reset(); app.clear_tracks(); app.set_status("Project reset".to_string()); } "q" | "quit" => { return Err("Quit requested".to_string()); } "help" | "h" | "?" => { // Show comprehensive help let help_msg = concat!( "Commands: ", "play | pause | stop | seek | ", "track | audiotrack | select | ", "clip | ", "loadmidi [start] | ", "reset | quit | help | ", "Keys: ←/→ scroll | -/+ zoom" ); app.set_status(help_msg.to_string()); } _ => { return Err(format!("Unknown command: '{}'. Type 'help' for commands", parts[0])); } } Ok(()) }