Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

232 changes: 173 additions & 59 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod documents;
mod panel;

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};
Expand All @@ -17,27 +18,50 @@ use tauri::{PhysicalPosition, PhysicalSize};

static ANIMATION_GENERATION: AtomicU64 = AtomicU64::new(0);

/// Serializa toda mutação de bounds da janela.
///
/// A checagem de geração e o `SetWindowPos` do frame precisam ser atômicos entre
/// si: sem isso, `set_window_bounds` podia entrar entre as duas e aplicar os
/// bounds finais, e o frame intermediário já calculado pousava por cima deles —
/// a animação retornava `false` (cancelada) com a janela parada no meio do
/// caminho.
static WINDOW_MUTATION: Mutex<()> = Mutex::new(());

// 60fps. A cada frame o WebView2 refaz o layout do DOM inteiro da janela
// (SetWindowPos dispara resize do webview); a ~144fps isso não sustenta e
// a animação trepida.
const FRAME_INTERVAL: Duration = Duration::from_micros(16667);

#[derive(Deserialize)]
pub struct TargetBounds {
x: i32,
y: i32,
width: u32,
height: u32,
/// Mesmo default do front (`DEFAULT_ANIMATION_DURATION_MS`).
const DEFAULT_ANIMATION_DURATION_MS: u64 = 200;

fn lock_window_mutation() -> MutexGuard<'static, ()> {
// Um panic em outra mutação não pode travar as janelas do app para sempre.
WINDOW_MUTATION
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}

#[derive(Serialize)]
pub struct WorkArea {
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bounds {
x: i32,
y: i32,
width: u32,
height: u32,
}

/// Como o `SetWindowPos` cruza para a thread dona da HWND.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ApplyMode {
/// Bloqueia até a thread do message pump processar — ou seja, até o
/// relayout inteiro do WebView2 terminar. Necessário quando quem chamou
/// precisa assumir que a janela já está no destino quando o comando resolve.
Blocking,
/// Enfileira o pedido (`SWP_ASYNCWINDOWPOS`) em vez de bloquear. Pedidos
/// enfileirados são processados em ordem entre si.
Queued,
}

fn ease_out_cubic(t: f64) -> f64 {
1.0 - (1.0 - t).powi(3)
}
Expand All @@ -46,26 +70,62 @@ fn lerp(start: f64, end: f64, t: f64) -> f64 {
start + (end - start) * t
}

fn interpolate(from: Bounds, to: Bounds, eased: f64) -> Bounds {
Bounds {
x: lerp(f64::from(from.x), f64::from(to.x), eased).round() as i32,
y: lerp(f64::from(from.y), f64::from(to.y), eased).round() as i32,
width: lerp(f64::from(from.width), f64::from(to.width), eased).round() as u32,
height: lerp(f64::from(from.height), f64::from(to.height), eased).round() as u32,
}
}

/// Sobe a resolução do timer do sistema para 1ms enquanto existir.
///
/// `std::thread::sleep` no Windows vira `Sleep()`, que arredonda para cima até o
/// próximo tick do timer do sistema — ~15,6ms por padrão. Um sleep de 16,6ms
/// vira 31,2ms e o período de frame passa a variar na faixa dos 40fps, o que
/// sozinho já explica trepidação independente do WebView2. O par
/// `timeBeginPeriod`/`timeEndPeriod` é refcontado pelo SO, então guards
/// concorrentes se compõem.
#[cfg(windows)]
fn apply_bounds(
window: &WebviewWindow,
x: i32,
y: i32,
width: u32,
height: u32,
) -> Result<(), String> {
use windows_sys::Win32::UI::WindowsAndMessaging::{SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER};
struct HighResTimer;

#[cfg(windows)]
impl HighResTimer {
fn acquire() -> Self {
unsafe { windows_sys::Win32::Media::timeBeginPeriod(1) };
Self
}
}

#[cfg(windows)]
impl Drop for HighResTimer {
fn drop(&mut self) {
unsafe { windows_sys::Win32::Media::timeEndPeriod(1) };
}
}

#[cfg(windows)]
fn apply_bounds(window: &WebviewWindow, bounds: Bounds, mode: ApplyMode) -> Result<(), String> {
use windows_sys::Win32::UI::WindowsAndMessaging::{
SetWindowPos, SWP_ASYNCWINDOWPOS, SWP_NOACTIVATE, SWP_NOZORDER,
};

let mut flags = SWP_NOZORDER | SWP_NOACTIVATE;
if mode == ApplyMode::Queued {
flags |= SWP_ASYNCWINDOWPOS;
}

let hwnd = window.hwnd().map_err(|e| e.to_string())?;
let ok = unsafe {
SetWindowPos(
hwnd.0 as _,
std::ptr::null_mut(),
x,
y,
width as i32,
height as i32,
SWP_NOZORDER | SWP_NOACTIVATE,
bounds.x,
bounds.y,
bounds.width as i32,
bounds.height as i32,
flags,
)
};
if ok == 0 {
Expand All @@ -74,63 +134,110 @@ fn apply_bounds(
Ok(())
}

/// Duas mutações: dois relayouts e um "shear" visível (redimensiona, depois
/// move). `WebviewWindow::set_bounds` do Tauri 2.11 não resolve — ele mexe na
/// área de cliente do *webview*, não no frame da janela. O caminho nativo no
/// macOS é deixar o AppKit animar (`NSAnimationContext`) em vez do loop em Rust.
#[cfg(not(windows))]
fn apply_bounds(
window: &WebviewWindow,
x: i32,
y: i32,
width: u32,
height: u32,
) -> Result<(), String> {
fn apply_bounds(window: &WebviewWindow, bounds: Bounds, _mode: ApplyMode) -> Result<(), String> {
window
.set_size(PhysicalSize::new(width, height))
.set_size(PhysicalSize::new(bounds.width, bounds.height))
.map_err(|e| e.to_string())?;
window
.set_position(PhysicalPosition::new(x, y))
.set_position(PhysicalPosition::new(bounds.x, bounds.y))
.map_err(|e| e.to_string())?;
Ok(())
}

/// Reenfileira os bounds atrás de frames que a animação já postou.
///
/// `SetWindowPos` síncrono cruza a thread como mensagem *enviada*, e mensagens
/// enviadas passam na frente das postadas: um frame de animação postado antes do
/// cancelamento seria processado *depois* dos bounds finais e deixaria a janela
/// no estado intermediário. Repostar o destino o coloca no fim da mesma fila.
/// Como os bounds são idênticos aos que acabaram de ser aplicados, no caso comum
/// não gera `WM_SIZE` nem relayout.
#[cfg(windows)]
fn requeue_bounds(window: &WebviewWindow, bounds: Bounds) -> Result<(), String> {
apply_bounds(window, bounds, ApplyMode::Queued)
}

#[cfg(not(windows))]
fn requeue_bounds(_window: &WebviewWindow, _bounds: Bounds) -> Result<(), String> {
Ok(())
}

/// Checa a geração e aplica o frame sob o mesmo lock.
/// `Ok(false)` = a animação foi superada e não deve tocar mais na janela.
fn apply_frame(window: &WebviewWindow, bounds: Bounds, generation: u64) -> Result<bool, String> {
let _guard = lock_window_mutation();
if ANIMATION_GENERATION.load(Ordering::SeqCst) != generation {
return Ok(false);
}
apply_bounds(window, bounds, ApplyMode::Queued)?;
Ok(true)
}

fn run_animation(
window: &WebviewWindow,
to: &TargetBounds,
to: Bounds,
duration_ms: u64,
generation: u64,
) -> Result<bool, String> {
let from_pos = window.outer_position().map_err(|e| e.to_string())?;
let from_size = window.outer_size().map_err(|e| e.to_string())?;
let from = Bounds {
x: from_pos.x,
y: from_pos.y,
width: from_size.width,
height: from_size.height,
};

// Sem isto o pacing abaixo não vale nada: o sleep é arredondado para o tick
// do timer do sistema.
#[cfg(windows)]
let _timer = HighResTimer::acquire();

let start = Instant::now();
let duration = Duration::from_millis(duration_ms.max(1));
let mut frame: u32 = 0;
// Começa com os bounds atuais já marcados como aplicados: o frame em t = 0
// reaplicaria exatamente eles, um relayout do WebView2 de graça.
let mut last = from;

loop {
if ANIMATION_GENERATION.load(Ordering::SeqCst) != generation {
return Ok(false);
}

let t = (start.elapsed().as_secs_f64() / duration.as_secs_f64()).min(1.0);
let eased = ease_out_cubic(t);

let x = lerp(from_pos.x as f64, f64::from(to.x), eased).round() as i32;
let y = lerp(from_pos.y as f64, f64::from(to.y), eased).round() as i32;
let width = lerp(from_size.width as f64, f64::from(to.width), eased).round() as u32;
let height = lerp(from_size.height as f64, f64::from(to.height), eased).round() as u32;
let done = t >= 1.0;
let next = interpolate(from, to, ease_out_cubic(t));

apply_bounds(window, x, y, width, height)?;
// Na cauda do ease-out vários frames seguidos arredondam para os mesmos
// pixels. Reaplicar não move nada e custa um relayout completo do DOM.
if next != last {
match apply_frame(window, next, generation) {
Ok(true) => last = next,
Ok(false) => return Ok(false),
Err(err) => {
// Um Err no meio deixaria a janela num tamanho intermediário;
// pior que um Err com ela no destino.
let _ = apply_frame(window, to, generation);
return Err(err);
}
}
}

if t >= 1.0 {
if done {
return Ok(true);
}

// Dorme até o próximo múltiplo de FRAME_INTERVAL, não um intervalo fixo
// depois do trabalho: `SetWindowPos` é síncrono e força relayout do
// WebView2, então "trabalho + 16.6ms" dava um período de frame variável
// (~40fps irregular) — é isso que se vê como trepidação. Se um frame
// estourou o deadline, segue direto pro próximo em vez de acumular atraso.
frame += 1;
// Índice do frame derivado do tempo decorrido, não incrementado. Com um
// contador, um frame que estoura o orçamento deixava o índice atrás do
// relógio: `checked_duration_since` retornava `None` várias iterações
// seguidas e o loop rodava sem dormir até recuperar — o frame atrasado
// virava uma rajada de `SetWindowPos`, que é o que se enxerga como
// trepidação. Derivando, um frame perdido é pulado, não recuperado.
let elapsed = start.elapsed();
let next_frame = (elapsed.as_micros() / FRAME_INTERVAL.as_micros()) as u32 + 1;
if let Some(remaining) =
(start + FRAME_INTERVAL * frame).checked_duration_since(Instant::now())
(start + FRAME_INTERVAL * next_frame).checked_duration_since(Instant::now())
{
std::thread::sleep(remaining);
}
Expand All @@ -144,20 +251,24 @@ fn run_animation(
/// Tauri que no Wry — tauri-apps/tauri#6322). Para transições, o caminho liso é
/// redimensionar de uma vez e animar o conteúdo por transform no CSS.
#[tauri::command]
fn set_window_bounds(window: WebviewWindow, to: TargetBounds) -> Result<(), String> {
fn set_window_bounds(window: WebviewWindow, to: Bounds) -> Result<(), String> {
let _guard = lock_window_mutation();
// Invalida animação em voo para ela não sobrescrever estes bounds.
ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
apply_bounds(&window, to.x, to.y, to.width, to.height)
// Síncrono: o front assume que a janela já está no tamanho final quando isto
// resolve (é o que impede o painel de pintar recortado dentro da pílula).
apply_bounds(&window, to, ApplyMode::Blocking)?;
requeue_bounds(&window, to)
}

#[tauri::command]
fn monitor_work_area(window: WebviewWindow) -> Result<WorkArea, String> {
fn monitor_work_area(window: WebviewWindow) -> Result<Bounds, String> {
let monitor = window
.current_monitor()
.map_err(|e| e.to_string())?
.ok_or_else(|| "no monitor found".to_string())?;
let work_area = monitor.work_area();
Ok(WorkArea {
Ok(Bounds {
x: work_area.position.x,
y: work_area.position.y,
width: work_area.size.width,
Expand All @@ -168,13 +279,16 @@ fn monitor_work_area(window: WebviewWindow) -> Result<WorkArea, String> {
#[tauri::command]
async fn animate_window_bounds(
window: WebviewWindow,
to: TargetBounds,
to: Bounds,
duration_ms: Option<u64>,
) -> Result<bool, String> {
let duration = duration_ms.unwrap_or(200);
let generation = ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
let duration = duration_ms.unwrap_or(DEFAULT_ANIMATION_DURATION_MS);
let generation = {
let _guard = lock_window_mutation();
ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst) + 1
};

tauri::async_runtime::spawn_blocking(move || run_animation(&window, &to, duration, generation))
tauri::async_runtime::spawn_blocking(move || run_animation(&window, to, duration, generation))
.await
.map_err(|e| e.to_string())?
}
Expand Down
Loading