# Implementação — Reordenar Faixas Externas (botões ↑↓) **Data:** 01/03/2026 **Status:** ✅ Implementado **Prioridade:** Baixa — melhoria de usabilidade **Arquivos afetados:** - `src/domain/entities/project.rs` - `src/ui/components/added_track_list.rs` - `src/ui/app.rs` --- ## Objetivo Permitir que o usuário reordene as faixas externas adicionadas (áudio e legenda) usando botões ↑↓ na lista. A ordem do `Vec` em `project.tracks` determina diretamente o índice de stream no container MKV final — já que ambos os command builders (`FfmpegCommandBuilder` e `MkvmergeCommandBuilder`) iteram esse Vec posicionalmente a cada `build()`. --- ## Por que botões ↑↓ e não drag-and-drop | Critério | Botões ↑↓ | Drag-and-drop (egui) | | ------------------------ | --------------------------------------- | -------------------------------------------- | | Esforço de implementação | ~1h | ~5–6h | | Risco de regressão | Nulo — mudanças aditivas | Baixo, mas requer estado extra no componente | | Clareza de UX | Explícita, sem ambiguidade | Mais fluido, porém menos óbvio em grids | | Testabilidade | Método de domínio testável isoladamente | Lógica de UI difícil de testar | **Conclusão:** botões ↑↓ entregam 100% do valor com ~15% do esforço. --- ## Análise da arquitetura atual A ordem das faixas externas já é a fonte de verdade do índice no output: ```rust // FfmpegCommandBuilder::build() — sem cache de índice let mut ext_idx = external_input_start; for track in &project.tracks { // ← itera em ordem args.push("-map".to_string()); // ... ext_idx += 1; } ``` ```rust // MkvmergeCommandBuilder::build() — idem for track in &project.tracks { // ← itera em ordem // cada arquivo externo vira um input separado } ``` `TrackId` é opaco (`TrackId(u32)`) — não representa posição, apenas identidade. Uma troca de posição no Vec não invalida nenhum `TrackId` existente. --- ## Passo 1 — Método `move_track` em `Project` **Arquivo:** `src/domain/entities/project.rs` Adicionar logo após `remove_track`: ```rust /// Move uma faixa externa para cima (`delta = -1`) ou para baixo (`delta = 1`). /// Retorna `true` se a faixa foi encontrada e o movimento era possível. pub fn move_track(&mut self, id: TrackId, delta: i8) -> bool { let Some(pos) = self.tracks.iter().position(|t| t.id() == id) else { return false; }; let new_pos = pos as i64 + delta as i64; if new_pos < 0 || new_pos >= self.tracks.len() as i64 { return false; } self.tracks.swap(pos, new_pos as usize); true } ``` --- ## Passo 2 — Novos eventos em `AddedTrackEvent` **Arquivo:** `src/ui/components/added_track_list.rs` ```rust pub enum AddedTrackEvent { RemoveRequested(TrackId), MoveUp(TrackId), // ← novo MoveDown(TrackId), // ← novo } ``` --- ## Passo 3 — Botões ↑↓ na grid **Arquivo:** `src/ui/components/added_track_list.rs` Alterar a assinatura do método para receber o índice atual e o total, e adicionar os botões na última coluna. A grid passa de 4 para 5 colunas (`num_columns(5)`). O cabeçalho ganha uma coluna `""` extra. Cada linha recebe os botões ↑ e ↓, desabilitados na primeira e última posição respectivamente: ```rust pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec { let mut events = Vec::new(); let total = tracks.len(); ui.group(|ui| { ui.heading("Faixas adicionadas"); if tracks.is_empty() { ui.label("Nenhuma faixa adicionada."); return; } egui::Grid::new("added_tracks_grid") .num_columns(5) // ← era 4 .max_col_width(200.0) .striped(true) .show(ui, |ui| { ui.strong("Tipo"); ui.strong("Arquivo"); ui.strong("Idioma"); ui.strong(""); // coluna de ordem ui.strong(""); // coluna de remover ui.end_row(); for (idx, track) in tracks.iter().enumerate() { // ... células existentes (tipo, arquivo, idioma) ... // ── Botões de ordenação ── ui.horizontal(|ui| { ui.add_enabled_ui(idx > 0, |ui| { if ui.small_button("↑").clicked() { events.push(AddedTrackEvent::MoveUp(track.id())); } }); ui.add_enabled_ui(idx + 1 < total, |ui| { if ui.small_button("↓").clicked() { events.push(AddedTrackEvent::MoveDown(track.id())); } }); }); if ui.small_button("🗑 Remover").clicked() { events.push(AddedTrackEvent::RemoveRequested(track.id())); } ui.end_row(); } }); }); events } ``` --- ## Passo 4 — Tratar eventos em `app.rs` **Arquivo:** `src/ui/app.rs` O trecho que já trata `RemoveRequested` precisa ser expandido: ```rust let track_events = self.added_track_list.ui(ui, &project.tracks); for event in track_events { use crate::ui::components::added_track_list::AddedTrackEvent; match event { AddedTrackEvent::RemoveRequested(id) => { let _ = RemoveTrack::execute(project, id); } AddedTrackEvent::MoveUp(id) => { project.move_track(id, -1); } AddedTrackEvent::MoveDown(id) => { project.move_track(id, 1); } } } ``` --- ## Passo 5 — Testes unitários para `move_track` **Arquivo:** `src/domain/entities/project.rs` — bloco `#[cfg(test)]` existente. ```rust #[test] fn move_track_para_cima() { let mut p = make_project("i.mkv", "o.mkv").unwrap(); let t1 = audio_track_with_drift(1, 1.0); let t2 = audio_track_with_drift(2, 1.0); let id1 = t1.id(); let id2 = t2.id(); p.tracks.push(t1); p.tracks.push(t2); assert!(p.move_track(id2, -1)); assert_eq!(p.tracks[0].id(), id2); assert_eq!(p.tracks[1].id(), id1); } #[test] fn move_track_para_baixo() { let mut p = make_project("i.mkv", "o.mkv").unwrap(); let t1 = audio_track_with_drift(1, 1.0); let t2 = audio_track_with_drift(2, 1.0); let id1 = t1.id(); let id2 = t2.id(); p.tracks.push(t1); p.tracks.push(t2); assert!(p.move_track(id1, 1)); assert_eq!(p.tracks[0].id(), id2); assert_eq!(p.tracks[1].id(), id1); } #[test] fn move_track_limite_superior_ignorado() { let mut p = make_project("i.mkv", "o.mkv").unwrap(); let t1 = audio_track_with_drift(1, 1.0); let id1 = t1.id(); p.tracks.push(t1); assert!(!p.move_track(id1, -1)); // já é o primeiro — não move } #[test] fn move_track_limite_inferior_ignorado() { let mut p = make_project("i.mkv", "o.mkv").unwrap(); let t1 = audio_track_with_drift(1, 1.0); let id1 = t1.id(); p.tracks.push(t1); assert!(!p.move_track(id1, 1)); // já é o último — não move } ``` --- ## Escopo e limitações - Somente faixas **externas** (`project.tracks`) são reordenáveis. Faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada — essa restrição deve ser comunicada visualmente (ex: tooltip ou nota abaixo da lista de faixas adicionadas). - A ordenação é persistida automaticamente via RF-14 (sessão salva em JSON inclui o Vec na nova ordem). - Sem impacto no modo Lote: cada `BatchItem` tem seu próprio `Project` independente; a mesma lógica se aplica. --- ## Checklist de implementação - [x] `Project::move_track()` adicionado e testado (4 testes novos) - [x] `AddedTrackEvent` com variantes `MoveUp` e `MoveDown` - [x] Grid de 5 colunas com botões ↑↓ desabilitados nas bordas - [x] Handler em `app.rs` tratando os dois novos eventos - [x] `cargo test` — 72 testes passando, 0 falhas - [x] `cargo check` sem warnings (3 warnings pré-existentes não relacionados)