Files
simple-multimidia-track-aud…/docs/impl-reorder-external-tracks.md

261 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Track>` 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 | ~56h |
| 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<AddedTrackEvent> {
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)