Dibujar las ventanas con egui: distribución, zoom, temas, iconos
Archivos: crates/lineup-app/src/main.rs,
crates/lineup-app/src/app.rs,
crates/lineup-app/src/theme.rs,
crates/lineup-app/src/widgets.rs,
crates/lineup-app/src/icons.rs,
crates/lineup-app/src/app/view.rs,
crates/lineup-app/src/app/shortcuts.rs
y crates/lineup-app/src/app/about.rs
La ventana se dibuja con egui, una librería de GUI escrita en Rust, y eframe, el framework de egui para ejecutarla como app nativa en Linux, macOS y Windows. Este capítulo trata de cómo se arma la ventana principal en cada frame, del aspecto, de cómo las filas de controles caben en cualquier tamaño de ventana, de los zooms, de las ventanas que flotan sobre la lista de reproducción y del menú Ayuda. Los editores en sí, el rack de canales, el piano roll y la lista de reproducción, tienen el capítulo siguiente.
Modo inmediato
La mayoría de los toolkits de GUI son de modo retenido: creas los objetos botón una vez, los guardas en un árbol y les adjuntas callbacks. egui es de modo inmediato: en cada frame, llamas a una función que describe toda la interfaz a partir de tus propios datos, y la librería la dibuja y te dice en qué se ha hecho clic.
#![allow(unused)]
fn main() {
if ui.button("▶ Play").clicked() {
self.set_playing(true);
}
}
Esa línea dibuja el botón y comprueba si se ha hecho clic en él. No hay objetos botón que mantener sincronizados con tu estado. El estado son simplemente los campos de tu struct, y la interfaz es siempre una imagen de él. Esto encaja bien con Rust, porque no hay callbacks que retengan referencias a tus datos.
El precio es que un frame no puede volver atrás. Algo dibujado al principio del frame no puede saber lo que se dibuja después. Varias piezas de este capítulo (la barra de ayuda, los grupos que se reparten en filas, el zoom) lo resuelven de la misma manera: usan lo que encontró el frame anterior, de hace una sexagésima de segundo.
main.rs
fn main() -> eframe::Result {
// The first command-line argument, if there is one, is a file to open,
// or what to do instead of opening the window.
let mut args = std::env::args_os().skip(1);
let first = args.next();
match first.as_ref().and_then(|arg| arg.to_str()) {
Some("--trace") => std::process::exit(trace(args)),
Some("--demos") => { /* print the demos' names */ return Ok(()); }
Some("--help" | "-h") => { /* print how to use it */ return Ok(()); }
_ => {}
}
let path = first.map(PathBuf::from);
let mut viewport = eframe::egui::ViewportBuilder::default()
.with_title(app::APP_NAME)
.with_inner_size([1280.0, 800.0])
.with_min_inner_size([800.0, 520.0])
.with_icon(window_icon());
// ...inside a Flatpak, the window takes the sandbox's app id...
let options = eframe::NativeOptions {
viewport,
..Default::default()
};
eframe::run_native(
app::APP_NAME,
options,
Box::new(|cc| Ok(Box::new(app::LineupApp::new(cc, path)))),
)
}
maindevuelve unResult. Si devuelveErr, Rust imprime el error y sale con un código de fallo.ViewportBuilder::default().with_title(...)es el patrón builder: cada método toma el builder y lo devuelve modificado, para que las llamadas se encadenen...Default::default()es la sintaxis de actualización de structs: «todo campo que no enumere toma su valor por defecto».Box::new(|cc| ...)pasa un closure que eframe llama una vez para crear nuestra app.Boxpone un valor en el montículo. Aquí hace falta porque eframe acepta cualquier tipo que implemente su traitApp, y para guardar un valor cuyo tipo exacto no conoce necesita un puntero a él:Box<dyn App>. La palabra clavedynsignifica «algún tipo que implementa este trait, decidido en tiempo de ejecución» (un trait object)."--help" | "-h"es un patrón «o»: la rama coincide con cualquiera de las dos cadenas.
LineupApp: el estado
LineupApp guarda todo lo que necesita la ventana: el proyecto, el
historial de deshacer, las ventanas abiertas y su propio estado, el
archivo, los ajustes y la conexión con el hilo de audio. Esa conexión es
un enum:
#![allow(unused)]
fn main() {
enum Audio {
/// Boxed: the engine is much bigger than a `String`, and every
/// `Audio` would otherwise take as much room as the biggest variant.
Running(Box<AudioEngine>),
Unavailable(String),
}
}
Captura una situación real: o hay un motor en marcha, o está la razón
por la que no lo hay. No puedes tener ambas cosas, y no puedes no tener
ninguna, así que el tipo no puede representar un estado imposible.
Compáralo con la alternativa habitual, un Option<AudioEngine> más un
Option<String>, que permite cuatro combinaciones de las cuales dos no
tienen sentido.
Un enum ocupa lo que su variante más grande, más un poco de espacio
para decir cuál es. Box deja el motor, que es grande, en el
montículo, así que Audio ocupa lo que un puntero o un String, lo
que sea mayor. Clippy lo señala con su lint large_enum_variant.
Usarlo:
#![allow(unused)]
fn main() {
fn send(&mut self, command: Command) {
if let Audio::Running(engine) = &mut self.audio {
engine.send(command);
}
}
}
if let es un match de una sola rama: si self.audio es Running,
liga el motor que hay dentro a engine y ejecuta el bloque. Hacer match
sobre &mut self.audio toma prestado el motor de forma mutable en lugar
de moverlo fuera del struct.
Si la computadora no tiene una tarjeta de sonido que funcione, la app se abre igual y la barra de estado dice por qué, en naranja: «Audio no disponible: …». Un fallo se convierte en datos que mostramos, en lugar de un cierre inesperado.
Un frame
eframe llama a ui cada vez que la ventana necesita repintarse:
#![allow(unused)]
fn main() {
impl eframe::App for LineupApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
if let Audio::Running(engine) = &mut self.audio {
engine.collect_garbage();
}
self.check_trace();
let ctx = ui.ctx().clone();
self.before_drawing(&ctx);
self.panels(ui, &ctx);
let playing = self.song_position().filter(|_| self.playing);
let area = self.main_area(ui, &ctx, playing);
// The channel rack and the piano roll float on top of the playlist.
let playhead = self.pattern_playhead();
self.bring_window_to_front(&ctx);
self.rack_window(&ctx, area, playhead);
self.piano_roll_window(&ctx, area, playhead);
// The chord sheet is drawn last, so it opens over the others.
self.chord_sheet_window(&ctx, area, playing);
// ...ear training, the lead sheet, the mixer, the shortcuts,
// About, the style editor, Settings and the dialogs...
self.unsaved_changes_dialog(&ctx);
self.update_title(&ctx);
self.end_frame_for_undo(&ctx);
}
fn on_exit(&mut self) {
self.forget_recovery();
}
}
}
El orden es el diseño:
collect_garbagelibera lo que devolvió el hilo de audio (ver La cola de basura).before_drawinghace todo lo que debe pasar antes de dibujar nada: apunta si un clic está cerrando un menú, pone el idioma y los nombres de las notas para el frame, cierra un paso de deshacer al pulsar el ratón, escribe la copia de recuperación cuando toca, lee los atajos de teclado y las teclas de zoom, lee la entrada MIDI y mira cómo van las exportaciones e importaciones que corren en segundo plano. Mientras suena, le pide a egui otro frame enseguida (ctx.request_repaint()), para que el cursor de reproducción se mueva; si no, egui solo dibuja cuando pasa algo.panelsdibuja las barras de los bordes: los menús con la barra de ayuda, la barra de herramientas, la barra de estado y las teclas de armonía. Los paneles deben ir antes del área central, que ocupa el espacio que queda.main_areadibuja la lista de reproducción (o la hoja de acordes, cuando llena la ventana principal) y devuelve el rectángulo que ocupó.- Las ventanas flotan sobre ese rectángulo. Las que se dibujan después quedan encima, por eso la hoja de acordes va la última de los tres editores.
end_frame_for_undodecide si el paso de deshacer termina (ver Deshacer y rehacer).
on_exit se ejecuta una vez cuando la app se cierra bien: no hay
trabajo sin guardar que recuperar, así que la copia de recuperación de
esta ejecución se borra.
Los muchos métodos self.algo(ui) mantienen ui corto. Cada closure
que se pasa a un panel puede tomar prestado self de forma mutable,
porque cada uno se ejecuta y termina antes de que empiece el siguiente,
algo que el borrow checker puede ver.
Un tema es una función
egui dibuja cada widget a partir de un Style: los colores en
style.visuals, los tamaños y huecos en style.spacing. Nada se pinta
con un color fijo salvo que el código pida uno. Así que todo el aspecto
es una función, theme::apply, llamada una vez al arrancar la app:
#![allow(unused)]
fn main() {
pub fn apply(ctx: &egui::Context) {
ctx.set_theme(egui::Theme::Dark);
ctx.style_mut_of(egui::Theme::Dark, |style| {
let visuals = &mut style.visuals;
visuals.panel_fill = PANEL;
visuals.window_fill = WINDOW;
visuals.selection.bg_fill = ACCENT; // selected and switched-on things
visuals.selection.stroke = Stroke::new(1.0, ON_ACCENT);
// ...shadows, corners, warning and error colours...
let widgets = &mut visuals.widgets;
for (state, fill) in [
(&mut widgets.inactive, BUTTON),
(&mut widgets.hovered, BUTTON_HOVER),
(&mut widgets.active, BUTTON_PRESSED),
(&mut widgets.open, BUTTON_HOVER),
] {
state.bg_fill = fill;
state.weak_bg_fill = fill;
state.bg_stroke = Stroke::new(1.0, OUTLINE);
// ...
}
// ...spacing...
});
}
}
El aspecto toma prestado de los estudios basados en patrones: paneles gris azulado oscuro, botones grises en relieve, una luz naranja para lo que está seleccionado o encendido, y pantallas «LCD» negras para números como el tempo. Tres cosas que hay que notar en el código:
style_mut_of(Theme::Dark, ...)cambia solo el estilo oscuro. egui guarda un estilo para oscuro y otro para claro, y la app usa siempre el oscuro.- El bucle
forrecorre un array de pares de referencias mutables. Cada&mut widgets.inactivetoma prestado un campo distinto, así que Rust permite varios a la vez: el borrow checker sigue los campos de un struct por separado. - Los colores son elementos
const:Color32::from_rgbes unconst fn, así que se ejecuta en tiempo de compilación y no cuesta nada cuando la app se ejecuta.
Las vistas siguen teniendo sus propias constantes para las cosas que solo dibujan ellas (las filas de la lista de reproducción, las teclas del piano roll, los botones de paso), con valores que pegan con el tema.
Cambiar el estilo de unos pocos widgets
La pantalla de tempo es un DragValue, el número de egui que se
arrastra o se escribe, pero dibujado en negro con dígitos verde
azulados. Su estilo se cambia solo dentro de su marco:
#![allow(unused)]
fn main() {
pub fn lcd_frame(
ui: &mut Ui,
label: &str,
width: f32,
add: impl FnOnce(&mut Ui) -> Response,
) -> Response {
wrapping(ui, |ui| {
egui::Frame::new()
.fill(LCD)
// ...
.show(ui, |ui| {
// ...the label above, written smaller if it is long...
ui.vertical(|ui| {
ui.label(label);
let visuals = &mut ui.visuals_mut().widgets;
for state in [&mut visuals.inactive, &mut visuals.hovered, &mut visuals.active] {
state.bg_fill = LCD;
state.fg_stroke.color = LCD_TEXT;
// ...
}
ui.style_mut().override_font_id = Some(FontId::monospace(16.0));
add(ui)
})
.inner
})
})
.inner
}
}
Cada Ui tiene su propia copia del estilo (un Arc<Style>; style_mut
hace una copia privada la primera vez que se cambia). El contenido del
marco recibe un Ui hijo, así que el cambio se queda dentro del marco y
el resto de la barra de herramientas no se toca. add es un closure que
devuelve el Response del widget, así que quien lo llama puede seguir
preguntando changed() fuera:
#![allow(unused)]
fn main() {
let tempo = theme::lcd_frame(ui, tr("TEMPO"), 62.0, |ui| {
ui.add(egui::DragValue::new(&mut bpm).range(Tempo::MIN..=Tempo::MAX))
});
}
El campo de Tempo es privado, así que el valor se copia a un bpm
local, el widget edita ese, y se construye un Tempo nuevo si cambió.
El Tempo sigue siendo válido se escriba lo que se escriba.
Tempo::MIN..=Tempo::MAX es un rango inclusivo: a..b deja fuera
b, a..=b lo incluye.
Una etiqueta más larga que la pantalla, como algunas en español, se escribe un poco más pequeña para que quepa, así que la barra de herramientas conserva su ancho en todos los idiomas.
La barra de ayuda: un mensaje del futuro
Junto a los menús, una línea dice qué hace el control que está bajo el ratón. Esa línea se dibuja primero, arriba del todo de la ventana, pero los widgets que saben sobre qué está el ratón se dibujan después, en el mismo frame. En modo inmediato no hay vuelta atrás para repintar la barra.
Así que la barra muestra lo que se encontró en el frame anterior.
Los widgets dejan su ayuda en la memoria de egui, ctx.data, que es un
mapa de un Id a un valor de cualquier tipo:
#![allow(unused)]
fn main() {
pub fn hint(response: Response, text: impl Into<String>) -> Response {
let text = text.into();
if response.hovered() {
response
.ctx
.data_mut(|data| data.insert_temp(hint_id(), text.clone()));
}
response.on_hover_text(text)
}
}
Al principio del frame siguiente, la barra la saca, para que no se quede cuando el ratón se va:
#![allow(unused)]
fn main() {
pub fn start_frame(&mut self, ctx: &egui::Context) {
let text = ctx.data_mut(|data| data.remove_temp::<String>(hint_id()));
if text != self.text {
// The mouse moved onto or off a widget: draw once more, so the
// bar does not lag a frame behind until the next event.
ctx.request_repaint();
}
self.text = text;
}
}
remove_temp::<String> usa el turbofish ::<String> para decir qué
tipo sacar: el mapa puede guardar valores de muchos tipos bajo el mismo
Id. El request_repaint es la parte sutil. egui solo dibuja un frame
cuando pasa algo. Cuando el ratón se pone sobre un botón, ese frame
apunta la ayuda, pero la barra sigue mostrando la antigua. Pedir un
frame más muestra la ayuda nueva enseguida, en lugar de la próxima vez
que se mueva el ratón.
Filas que caben en cualquier ventana
La barra de herramientas tiene el modo de reproducción, el transporte,
las pantallas de tempo y de posición, el selector de patrones, los cinco
botones de ventana, la cuenta de entrada, el metrónomo y el volumen
maestro. En una ventana de 1280 puntos de ancho cabe en una fila; una
prueba, the_toolbar_fits_on_one_row_at_1280_points, comprueba que siga
siendo así. Una ventana más estrecha o con zoom tiene que seguir
llegando a todos los controles.
egui coloca los widgets uno detrás de otro. ui.horizontal los pone en
una fila y sigue más allá del borde si no caben: lo que queda más allá
del borde sigue estando, solo que no se dibuja. ui.horizontal_wrapped
es la misma fila, salvo que un widget que no cabría empieza una fila
nueva. La barra de herramientas y la barra de teclas de armonía son
filas que se reparten, así que en una ventana estrecha su segunda parte
baja.
Grupos que se reparten: el ancho del frame pasado
Los botones de la barra de herramientas van en grupos, cada uno dentro
de un marco oscuro. Una fila que se reparte decide si pasa a la fila
siguiente preguntándole a un widget cuánto quiere ocupar antes de
colocarlo. Un marco no puede responder a eso: Frame::show dibuja su
contenido en el espacio que quede, y solo sabe su tamaño después. Así
que theme::wrapping usa el frame anterior. egui vuelve a dibujar la
ventana muchas veces por segundo, así que el ancho de la última vez es
una muy buena estimación:
#![allow(unused)]
fn main() {
let id = ui.next_auto_id().with("wrapping");
let last_width = ui.ctx().data(|data| data.get_temp::<f32>(id)).or(guess);
let row_started = ui.cursor().min.x > ui.max_rect().min.x;
if let Some(width) = last_width
&& ui.layout().main_wrap
&& row_started
&& width > ui.available_size_before_wrap().x
{
ui.end_row();
}
let response = add(ui);
let width = response.response.rect.width();
ui.ctx().data_mut(|data| data.insert_temp(id, width));
}
ui.next_auto_id()es el id que egui le daría al siguiente widget. Depende de cuántos widgets vinieron antes en esteUi, así que es el mismo en cada frame mientras la barra se dibuje en el mismo orden. Eso lo convierte en una clave para recordar algo de un grupo.get_temp::<f32>le pide a la memoria de egui unf32; el turbofish le dice a Rust qué tipo buscar, porque nada más en la línea lo dice.- La cadena
if let ... && ...(una «let chain») solo ejecutaend_rowcuando hay un ancho de la vez anterior, la distribución se reparte, la fila ya tiene algo y el grupo no cabe. El primer frame de todos todavía no tiene ancho, así que el grupo puede sobresalir durante un frame, cosa que nadie ve.
theme::group y lcd_frame envuelven sus marcos en él, y
keep_together ofrece lo mismo a cualquier grupo de widgets, con un
guess para el primer frame de todos. Ese primer frame importa dentro
de una ventana: las ventanas de egui crecen para que quepa su
contenido, y un solo frame de contenido demasiado ancho basta para
ensanchar una ventana para siempre. La barra de herramientas del piano
roll mantiene juntos sus grupos de esta manera, así que un nombre de
escala largo (el de Fa# mayor, «Fa# Sol# La# Si Do# Re# Mi#») pasa su
grupo a una segunda fila en lugar de ensanchar la ventana.
Texto que se acorta, y de derecha a izquierda
Repartirse en varias filas les va bien a los botones. Para una línea
larga de texto, como un mensaje de estado, a menudo es mejor cortarla.
Todo texto en egui tiene un modo de ajuste: Wrap lo parte en más
líneas, Truncate lo corta con «…» y Extend lo deja seguir. Un área
entera puede ponerlo para todo su texto mediante su estilo:
#![allow(unused)]
fn main() {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
}
Una etiqueta truncada muestra el texto entero cuando apuntas a ella, así que no se pierde nada.
La barra de estado tiene dos partes: mensajes a la izquierda y los controles de zoom de la lista de reproducción a la derecha. Si los mensajes se dibujaran primero, uno largo («Audio no disponible: no se pudieron leer los ajustes del dispositivo…») ocuparía todo el espacio y empujaría fuera el zoom. Así que la barra se distribuye de derecha a izquierda: el zoom entra primero, desde el borde derecho, y los mensajes se quedan con lo que sobra:
#![allow(unused)]
fn main() {
fn status_bar(&self, ui: &mut egui::Ui) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
view::zoom_control(ui);
ui.separator();
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
self.status_messages(ui);
});
});
}
}
En una distribución de derecha a izquierda, el primer widget es el que
está más a la derecha, por eso zoom_control añade su botón + antes
del porcentaje y del botón −.
Zoom
Hay dos tipos de zoom.
El tamaño de la interfaz hace todo más grande o más pequeño: texto,
botones, menús. egui dibuja en puntos, no en píxeles. Cuántos píxeles
ocupa un punto es pixels_per_point: la escala propia de la pantalla
(1.0, 1.5 o 2.0 en la mayoría) multiplicada por el factor de zoom de
egui. ctx.set_zoom_factor cambia el segundo.
Las áreas hacen zoom de lo que dibujan sin cambiar el texto. Como en la mayoría de los programas de música, la lista de reproducción y el piano roll hacen zoom en sus dos direcciones por separado: el zoom horizontal hace más anchos los compases y los pasos (menos tiempo en pantalla, con más detalle), el vertical hace más altas las pistas y las teclas del piano. Los pasos del rack de canales son botones cuadrados, así que tiene un solo zoom para las dos direcciones.
| Puntero sobre | Ctrl y + / − (o rueda) | Alt y + / − (o rueda) | Ctrl+0 |
|---|---|---|---|
| Lista de reproducción | compases más anchos / estrechos | pistas más altas / bajas | las dos al 100% |
| Piano roll | pasos más anchos / estrechos | teclas más altas / bajas | las dos al 100% |
| Rack de canales | más grande / pequeño | lo mismo | 100% |
| Cualquier otra cosa, también la hoja de acordes | interfaz más grande / pequeña | lo mismo | 100% |
¿Qué hay bajo el puntero?
Las teclas y la rueda hacen zoom de lo que está bajo el puntero. En
Rust, «una de estas clases» es un enum, y una variante puede llevar un
valor:
#![allow(unused)]
fn main() {
pub(crate) enum Target {
Area(Area), // Area::Main (the playlist), Area::Rack, Area::PianoRoll
Interface,
}
}
Target::at lo encuentra. egui mantiene una pila de capas: el fondo,
donde se dibujan los paneles, luego cada ventana encima, luego los menús
y los tooltips. ctx.layer_id_at(pos) dice qué capa está arriba en un
punto:
#![allow(unused)]
fn main() {
fn at(ctx: &egui::Context, pos: egui::Pos2, playlist: egui::Rect) -> Target {
match ctx.layer_id_at(pos) {
Some(layer) if layer.id == super::rack_id() => Target::Area(Area::Rack),
Some(layer) if layer.id == PianoRoll::id() => Target::Area(Area::PianoRoll),
Some(layer) if layer.order == egui::Order::Background && playlist.contains(pos) => {
Target::Area(Area::Main)
}
_ => Target::Interface,
}
}
}
Cada rama tiene una guarda, el if después del patrón. El patrón
Some(layer) coincide con cualquier capa, y la rama solo se toma si la
guarda también es verdadera; si no, match prueba la rama siguiente. La
última rama, _, recoge todo lo que queda.
Las barras de herramientas, las teclas de armonía y la lista de
reproducción se dibujan todas en la misma capa de fondo, así que la capa
sola no puede distinguirlas. Por eso la lista de reproducción recuerda
dónde se dibujó, y la guarda comprueba playlist.contains(pos). Las
teclas de zoom se leen al principio de un frame, antes de dibujar nada,
así que este es el rectángulo del frame anterior. Si el puntero está
fuera de la ventana, las teclas hacen zoom de lo último en que se hizo
clic.
La hoja de acordes es texto, así que sigue el tamaño de la interfaz, en
su propia ventana o en el lugar de la lista de reproducción. En ese
lugar tapa la lista de reproducción, y el último rectángulo de la lista
mentiría, así que zoom_shortcuts pasa uno vacío en su lugar:
#![allow(unused)]
fn main() {
let playlist = match self.chord_sheet.fills_window() {
true => egui::Rect::NOTHING,
false => self.playlist.area(),
};
}
Rect::NOTHING no contiene ningún punto, así que la guarda nunca es
verdadera, y las teclas caen en Target::Interface.
Todas las maneras de hacer zoom (teclas, rueda, pellizco en el touchpad)
llaman a los mismos pocos métodos de Target, y solo ellos miran de qué
clase es:
#![allow(unused)]
fn main() {
fn step(self, ctx: &egui::Context, axis: Axis, bigger: bool) {
match self {
Target::Area(area) => area.step_zoom(ctx, axis, bigger),
Target::Interface => {
ctx.set_zoom_factor(interface_step(ctx.zoom_factor(), bigger));
}
}
}
}
Target::Area(area) comprueba la variante y a la vez saca de ella el
Area a la variable area. La interfaz tiene un solo tamaño, así que
ahí axis no se usa: Ctrl y Alt hacen lo mismo.
Un zoom por área y por eje
Cada área guarda un número por dirección, en la memoria de egui, bajo una clave hecha de las dos cosas:
#![allow(unused)]
fn main() {
fn key(self, axis: Axis) -> egui::Id {
let axis = if self.two_way() { axis } else { Axis::Horizontal };
egui::Id::unique(("zoom-2", self, axis))
}
}
El rack no es two_way, así que sus dos ejes comparten la clave
horizontal: hacerle zoom «vertical» le hace zoom en las dos direcciones.
Los valores se guardan con insert_persisted, así que egui los guarda
con las posiciones de las ventanas y son los mismos la próxima vez que
arranca la app. El nombre "zoom-2" está porque un significado anterior
de «zoom» (una escala para todo lo de la ventana, texto incluido) se
guardaba bajo "zoom": un nombre nuevo hace que los valores viejos
simplemente no se encuentren, en lugar de leerse de la manera
equivocada.
Todas las maneras de hacer zoom por pasos pasan por step_zoom, que
recorre una escalera de tamaños:
#![allow(unused)]
fn main() {
const STEPS: [f32; 19] = [
0.25, 0.33, 0.4, 0.5, 0.6, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0,
8.0,
];
}
Cada paso es más o menos una quinta parte mayor que el anterior, así que cuesta tantos pasos ir de 100% a 200% como de 400% a 800%. Así es como el zoom se siente natural: los ojos comparan tamaños por proporción, no por diferencia. Cada área y eje tiene su propio tramo dentro de la escalera (las pistas y las teclas solo pueden achicarse hasta que sus nombres dejan de caber), y los botones se ponen grises en los extremos. Los tamaños de la interfaz son una lista más corta propia, del 50% al 250%, los mismos que ofrece Ajustes > Apariencia.
Leer la rueda nosotros mismos
egui da un único zoom_delta() para Ctrl+rueda, sin idea de dirección.
Así que la app lee los eventos de rueda en bruto del frame y los
clasifica según las teclas modificadoras pulsadas:
#![allow(unused)]
fn main() {
for event in &input.raw.events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
// Up or right zooms in. Shift turns a wheel sideways.
let amount = if delta.y != 0.0 { delta.y } else { delta.x };
if modifiers.alt {
tall.add(*unit, amount);
} else if modifiers.command {
wide.add(*unit, amount);
}
}
}
}
if let con un patrón es como Rust elige una variante de un enum y
nombra sus campos al mismo tiempo; .. ignora los campos que no
necesitamos.
En Linux (X11), un golpe de rueda a veces llega como dos eventos, y hacer zoom de dos pasos por golpe se siente a saltos. Así que los golpes se suman y hacen zoom de un paso como mucho por frame:
#![allow(unused)]
fn main() {
fn apply(self, target: Target, ctx: &egui::Context, axis: Axis) {
if self.notches != 0.0 {
target.step(ctx, axis, self.notches > 0.0);
}
if self.smooth != 1.0 {
target.scale(ctx, axis, self.smooth);
}
}
}
Un frame dura unos pocos milisegundos, así que girar la rueda rápido
sigue haciendo zoom paso a paso. Un touchpad manda en cambio muchos
movimientos diminutos en lugar de golpes (MouseWheelUnit::Point);
esos multiplican el zoom de forma suave.
Hacer zoom sobre lo que señalas
Cuando el zoom cambia, el paso y la nota que están bajo el puntero deben
seguir bajo el puntero. La cuadrícula está dentro de un scroll area, así
que eso significa elegir un desplazamiento nuevo. Lo que está bajo el
puntero está en from + anchor dentro del contenido, donde from es
cuánto se ha desplazado la vista y anchor dónde está el puntero dentro
de la vista. Hacer zoom por ratio lo mueve a (from + anchor) * ratio.
Para que se quede bajo el puntero, el desplazamiento nuevo debe ser eso
menos anchor. En el piano roll:
#![allow(unused)]
fn main() {
let x = KEY_WIDTH + (from.x + anchor.x - KEY_WIDTH) * ratio.x - anchor.x;
let y = (from.y + anchor.y) * ratio.y - anchor.y;
self.scroll_to = Some(vec2(x.max(0.0), y.max(0.0)));
}
Los términos KEY_WIDTH están porque el teclado de piano de la
izquierda no se estira: solo crece lo que está a su derecha.
Hubo que apagar una cosa más. Los scroll areas de egui tienen una
banda elástica: te desplazas más allá del final y la vista vuelve de
un salto. En el frame de un zoom, el scroll area todavía conoce el
tamaño viejo del contenido, así que el desplazamiento nuevo parece ir
más allá del final, y la vista se deslizaría de vuelta después de cada
zoom. widgets::no_rubber_band la apaga para las cuadrículas:
#![allow(unused)]
fn main() {
pub fn no_rubber_band(ui: &mut egui::Ui) {
ui.spacing_mut().scroll.kinetic.rubber_band = false;
}
}
El rack de canales se dibuja con widgets normales de egui, que no tienen
zoom propio. widgets::zoom_contents los hace más grandes multiplicando
los tamaños del texto y los espacios del estilo de la ventana, igual que
lcd_frame cambia sus colores: solo dentro del rack.
Ventanas sobre la lista de reproducción
El rack de canales, el piano roll, la hoja de acordes, el mezclador y
las demás son egui::Window, que flotan sobre todo. Se mantienen dentro
del rectángulo de la lista de reproducción con
Window::constrain_to(area), así que una ventana nunca tapa la barra de
herramientas, las teclas de armonía ni la barra de estado:
#![allow(unused)]
fn main() {
let playlist = egui::CentralPanel::default_margins().show(ui, |ui| { /* ... */ });
let area = playlist.response.rect;
}
Cada show de egui devuelve un InnerResponse: .inner es lo que
devolvió el closure (aquí las acciones de la lista de reproducción) y
.response dice dónde se dibujó.
Con qué ventanas se abre una canción
Lo que estudias en una canción son sus compases y acordes; lo que editas en un ritmo son los pasos de un patrón. Así que la app decide qué mostrar según lo que se abrió:
#![allow(unused)]
fn main() {
/// What a song opens on: the chord sheet in front, since its bars and
/// chords are what you study and write in a song, and the rack out of
/// the way (F6 brings it back).
fn show_chord_sheet(&mut self) {
self.chord_sheet.open = true;
self.rack_open = false;
self.bring_to_front = Some(chord_sheet::window_id());
}
/// What a beat opens on: the rack in front, and no chord sheet.
fn show_beat(&mut self) {
self.chord_sheet.open = false;
self.show_rack();
}
}
Las demostraciones de canciones, Archivo > Nuevo y una hoja de
acordes importada llaman a show_chord_sheet; Archivo > Nuevo ritmo
llama a show_beat. Abrir un archivo guardado deja las ventanas como
están, porque las elegiste tú. Una demostración es una canción o un
estilo, un enum, y solo las canciones se abren en la hoja:
#![allow(unused)]
fn main() {
if let Demo::Song(_) = demo {
self.show_chord_sheet();
}
}
La aplicación misma se abre con las ventanas que estaban abiertas la
última vez que se cerró. app/windows.rs las guarda como un struct
pequeño de bool, uno por cada ventana del menú Ver:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub(super) struct OpenWindows {
chord_sheet: bool,
lead_sheet: bool,
rack: bool,
piano_roll: bool,
harmony_keys: bool,
/// The Patterns and Library panel.
browser: bool,
mixer: bool,
ear_training: bool,
}
}
Su Default es la primera vez: la hoja de acordes, las teclas de
armonía y la Biblioteca sobre la lista de reproducción, sin rack.
#[serde(default)] en el struct rellena una ventana añadida más tarde a
la lista con su valor en Default, así que una copia guardada más
antigua se sigue leyendo. El editor de estilos y los diálogos quedan
fuera: cada uno se abre para algo, un estilo o una pregunta, que la
siguiente ejecución no tiene.
App::save, a la que eframe llama cada 30 segundos y cuando se cierra
la ventana, escribe el struct entre las preferencias guardadas por
nombre (ver Guardado por nombre, no por
tipo), y LineupApp::new lo vuelve
a leer:
#![allow(unused)]
fn main() {
fn save(&mut self, storage: &mut dyn eframe::Storage) {
self.preferences.put(windows::NAME, &self.open_windows());
self.preferences.save(storage);
}
}
#![allow(unused)]
fn main() {
let windows: OpenWindows = preferences::get(ctx, NAME).unwrap_or_default();
}
Escribirlas solo al guardar, en lugar de a cada clic en un botón de
ventana, libra de esa tarea a los muchos lugares que abren y cierran
ventanas.
the_app_first_opens_on_the_chord_sheet_then_with_the_windows_left_open
comprueba las dos ejecuciones: la primera se abre en la hoja, y después
de cerrarla con F5 y abrir el mezclador con F9, el siguiente arranque
encuentra el mezclador abierto y la hoja cerrada.
Ventanas del tamaño de lo que muestran
egui recuerda el tamaño de cada ventana y lo guarda con el resto de su memoria cuando la app se cierra. Eso está bien para una ventana en la que trabajas, como el piano roll: la hiciste grande porque la querías grande. Está mal para una ventana que es tan grande como lo que muestra: arrastra el mezclador a lo ancho una vez, y desde entonces se abriría por toda la lista de reproducción, con sus canales en una esquina. Así que hay dos tipos de ventana:
#![allow(unused)]
fn main() {
pub enum Opening {
/// Each time it opens: a window as big as what it shows.
EveryTime,
/// The first time it opens after the app starts: a window to work
/// in, which keeps the size you give it while the app runs.
FirstTime,
}
}
El mezclador, la lista de atajos, el entrenamiento auditivo, la serie dodecafónica, los conjuntos de clases de alturas y los micromodos se abren cada vez al tamaño de su contenido, hasta la altura de la lista de reproducción, y hacen scroll más allá. El piano roll, la hoja de acordes, la partitura guía y Ajustes se abren a su propio tamaño la primera vez después de que la app arranca, y conservan el tamaño que les des mientras se ejecuta.
widgets::open_at lo hace. Averigua si la ventana se está abriendo
ahora (no se dibujó en el último frame) y, si es así, le da un tamaño
fijo para ese único frame:
#![allow(unused)]
fn main() {
let first = ctx.data_mut(|data| {
!std::mem::replace(data.get_temp_mut_or(id.with("has-opened"), false), true)
});
if opens(ctx, id) && (first || opening == Opening::EveryTime) {
ctx.request_discard("a window opens at its own size");
window.fixed_size(size)
} else {
window
}
}
Tres cosas en él merecen una mirada:
std::mem::replace(place, new)ponenewenplacey devuelve lo que había. Aquí lee «¿se había abierto antes esta ventana?» y la marca como abierta, en un solo paso. La bandera se guarda conget_temp_mut_or, que egui no guarda, así que vuelve a serfalsecada vez que la app arranca.- egui acomoda una ventana a medida que la dibuja, así que en su primer
frame la ventana se dibuja antes de que nada sepa cuán grande es su
contenido.
request_discardle pide a egui que tire ese frame y lo dibuje otra vez; solo ves el segundo, ya al tamaño correcto. - El «tamaño» dado es un ancho estrecho y la mayor altura. egui agranda una ventana para que quepa un contenido más ancho, pero nunca la hace más alta que él, así que la ventana acaba exactamente tan grande como lo que muestra. Para que eso funcione, nada en ella puede llenar todo el espacio que se le da, como un scroll area que no se encoge hasta su contenido.
Una ventana cuyo contenido cambia mientras está abierta, como el
mezclador cuando se añade un canal o el rack cuando otro patrón tiene
menos canales, se ajusta otra vez con fit_when_changed. Calcula el
hash de un resumen del contenido (el nombre y el instrumento de cada
canal, por ejemplo) y ajusta la ventana otra vez cuando el hash cambia.
Clics que hacen lo que querías decir
Dos ayudantes de widgets.rs hacen que las cuadrículas respondan al
clic que querías hacer.
Un toque rápido. En un touchpad, un toque puede pulsar y soltar el
botón dentro de un mismo frame. El press_origin() de egui se olvida en
cuanto el botón está arriba, así que en ese frame es None. press_pos
lee en cambio la pulsación de la lista de eventos del frame, que además
dice dónde bajó el botón, no dónde está el puntero al final del frame:
#![allow(unused)]
fn main() {
pub fn press_pos(
input: &egui::InputState,
only: Option<egui::PointerButton>,
) -> Option<egui::Pos2> {
input.events.iter().find_map(|event| match *event {
egui::Event::PointerButton { pos, button, pressed: true, .. }
if only.is_none_or(|only| only == button) => Some(pos),
_ => None,
})
}
}
find_map recorre los eventos hasta que el closure devuelve Some, y
devuelve eso. Option::is_none_or se lee bien aquí: «no se pidió ningún
botón, o es este».
Un clic que cierra un menú. Abre una lista desplegable y haz clic en la cuadrícula para cerrarla: egui cierra el menú, pero el clic también llega a la cuadrícula, que dibujaría una nota, colocaría un clip o encendería un paso. En la mayoría de los programas ese clic solo cierra el menú. Así que al principio de cada frame, antes de dibujar nada y mientras el menú que se va a cerrar sigue abierto, la app recuerda si el botón que ahora está abajo bajó mientras había un menú abierto:
#![allow(unused)]
fn main() {
pub fn track_dismissing(ctx: &egui::Context) {
let id = dismissing_id();
let (pressed, down, released) = ctx.input(|input| {
(
press_pos(input, None).is_some(),
input.pointer.any_down(),
input.pointer.any_released(),
)
});
let open = egui::Popup::is_any_open(ctx);
ctx.data_mut(|data| {
let dismissing = data.get_temp_mut_or(id, false);
if pressed {
*dismissing = open;
} else if !down && !released {
*dismissing = false;
}
});
}
}
Las cuadrículas preguntan dismissing(ctx) e ignoran la pulsación, el
arrastre y la soltada que le pertenecen. El valor vive en la memoria
temporal de egui, un lugar cómodo para un poco de estado compartido
entre widgets que no se conocen entre sí.
Recordar entre ejecuciones
egui guarda algo de estado propio entre frames en su memoria: el
factor de zoom, dónde está cada ventana, cuán ancho es un panel que se
puede redimensionar. eframe puede guardar esa memoria cuando la app se
cierra y cargarla cuando arranca. Solo lo hace con su feature
persistence, que viene apagada por defecto porque trae serde y
ron. Encenderla es una línea en el Cargo.toml del workspace:
eframe = { version = "0.37", features = ["persistence"] }
Con ella, eframe recuerda también el tamaño y la posición de la ventana
principal. Escribe un archivo llamado app.ron en el lugar donde cada
sistema guarda los datos de las apps (en Linux,
~/.local/share/ferrouslineup/), cada 30 segundos y cuando se cierra la
ventana.
La app guarda la mayoría de sus propias elecciones duraderas en la
misma memoria, con insert_persisted en lugar de insert_temp: los
zooms de las áreas, los ajustes, los archivos recientes, los filtros de
la biblioteca. Cada módulo guarda lo suyo bajo un Id propio, y eframe
los guarda todos juntos. Lo que se guarda con insert_temp se olvida
al cerrar la app.
Guardado por nombre, no por tipo
egui encuentra un valor en su memoria por dos cosas: el Id bajo el que
se guardó y su tipo de Rust, a través del TypeId del tipo. Un TypeId
es un número que el compilador calcula a partir de la ruta del tipo, sus
campos y el crate en el que está, y la parte del crate cambia con la
versión del crate y con la versión del compilador. Así que después de
una actualización de la app, o de compilar con un Rust más nuevo, o de
añadir un campo al tipo, get_persisted busca bajo un número nuevo y no
encuentra nada: el ajuste vuelve a su valor por defecto, y el valor
viejo queda sin leer en app.ron. El SoundFont lo mostró: con el mismo
app.ron, la versión 0.4.0 tocaba con GeneralUser GS y una compilación
numerada 0.4.1 lo tenía desactivado.
eframe tiene un almacenamiento propio, el mismo app.ron, que encuentra
los valores por un nombre: un &str que no cambia sea cual sea la
versión. eframe se lo da a la app solo dos veces: como cc.storage
cuando la app arranca, y a App::save, cada 30 segundos y al cerrar la
ventana. El módulo app/preferences.rs une las dos cosas. Guarda cada
preferencia por nombre, escrita como texto RON, en un único mapa:
#![allow(unused)]
fn main() {
/// Each preference by name, written as RON. egui's context holds one
/// handle to the map and the app another, to save it.
#[derive(Clone, Default)]
pub(super) struct Preferences(Arc<Mutex<BTreeMap<String, String>>>);
}
Un acceso al mapa vive en la memoria temporal de egui, así que cualquier
código con el Context llega a él, como a cualquier otro valor guardado
ahí:
#![allow(unused)]
fn main() {
/// The preference kept as `name`, if there is one and it can be read.
pub(super) fn get<T: DeserializeOwned>(ctx: &egui::Context, name: &str) -> Option<T> {
let text = Preferences::of(ctx).map().get(name).cloned()?;
ron::from_str(&text).ok()
}
/// Keeps `value` as the preference `name`.
pub(super) fn set<T: Serialize>(ctx: &egui::Context, name: &str, value: &T) {
if let Ok(text) = ron::to_string(value) {
Preferences::of(ctx).map().insert(name.to_string(), text);
}
}
}
La app lee el mapa del almacenamiento de eframe antes de crear nada más,
#![allow(unused)]
fn main() {
if let Some(storage) = cc.storage {
preferences::Preferences::of(&cc.egui_ctx).load(storage);
}
}
guarda otro acceso a él, y se lo devuelve a eframe para que lo escriba:
#![allow(unused)]
fn main() {
fn save(&mut self, storage: &mut dyn eframe::Storage) {
self.preferences.save(storage);
}
}
El Arc<Mutex<…>> deja que los dos accesos compartan un mismo mapa. El
bloqueo se mantiene solo mientras se lee o se escribe una línea, y solo
lo toma el hilo de la ventana. Como los valores son texto RON hasta que
se piden, un tipo puede cambiar sus campos y seguir leyendo lo que
guardó una versión anterior, con #[serde(default)] para los campos
que todavía no existían.
Los ajustes de sonido (SoundFonts y General
MIDI) se guardan así, como "sound-font", y
también las ventanas abiertas, como "open-windows". Las
demás elecciones de arriba siguen en la memoria de egui, así que una
actualización puede olvidarlas; pasar una al nuevo sistema es cuestión
de llamar a preferences::get y preferences::set con un nombre en
lugar de get_persisted e insert_persisted con un Id.
Features de Cargo
Una feature es un interruptor que ofrece un crate para encender código
opcional. Dentro de eframe, el código que guarda la memoria está detrás
de #[cfg(feature = "persistence")], así que sin la feature ni siquiera
se compila. Encender una feature en un crate puede encender features en
otros: la persistence de eframe enciende la de egui, que añade derives
de serde a los tipos que egui guarda en su memoria. Puedes ver lo que
ofrece un crate en la tabla [features] de su Cargo.toml, o en su
página de docs.rs.
Dibujos pintados, no escritos
Los cinco botones de ventana de la barra de herramientas y los
interruptores de cuenta de entrada y metrónomo son botones cuadrados con
un dibujo; el nombre, lo que muestra la ventana y su tecla aparecen
cuando el ratón está encima. Un emoji sería la manera fácil de dibujar
algo así, pero un emoji es un carácter, dibujado por una fuente, y
algunos sistemas no tienen ninguna fuente con esos caracteres: allí los
botones mostrarían cajas vacías. Así que los dibujos de icons.rs están
pintados, con líneas, rectángulos y polígonos, de la misma manera en
todos los sistemas.
Cada dibujo es una función sencilla con la misma firma, y Rust puede nombrar esa firma como un tipo:
#![allow(unused)]
fn main() {
/// A picture: drawn by a function of the painter, the square it fills
/// and the colour to draw it in.
pub type Picture = fn(&Painter, Rect, Color32);
}
fn(&Painter, Rect, Color32) es un tipo puntero a función.
Cualquier función con esa firma puede guardarse en una variable de tipo
Picture, ponerse en un array o pasarse a otra función, sin un Box ni
un genérico.
Cada dibujo se pinta en un cuadrado, y sus puntos se dan como fracciones del cuadrado, de 0 a 1, para que el mismo dibujo pueda pintarse a cualquier tamaño:
#![allow(unused)]
fn main() {
/// The point `x`, `y` of the way across and down `rect`, each from 0 to 1.
fn at(rect: Rect, x: f32, y: f32) -> Pos2 {
rect.min + vec2(x * rect.width(), y * rect.height())
}
/// The mixer: three faders, each at its own level.
pub fn mixer(painter: &Painter, rect: Rect, color: Color32) {
for (x, level) in [(0.2, 0.62), (0.5, 0.3), (0.8, 0.5)] {
line(painter, rect, (x, 0.0), (x, 1.0), color.gamma_multiply(0.6));
let knob = Rect::from_center_size(at(rect, x, level), vec2(6.0, 3.5));
painter.rect_filled(knob, 1.0, color);
}
}
}
La hoja de acordes es una página con nombres de acordes a dos por línea, el rack tres filas de pasos con algunos encendidos, el piano roll un teclado con tres notas, las teclas de armonía un teclado con dos teclas negras, el metrónomo su cuerpo y su brazo.
La barra de herramientas dibuja sus cinco botones de ventana a partir de
un array. Cada entrada guarda el nombre, el bool que dice si esa
ventana está abierta, el dibujo y la ayuda:
#![allow(unused)]
fn main() {
let windows: [(&str, &mut bool, icons::Picture, &str); 5] = [
("Chord sheet", &mut self.chord_sheet.open, icons::chord_sheet,
"the song's harmony bar by bar, to read and write (F5)"),
("Channel rack", &mut self.rack_open, icons::rack,
"the steps of the selected pattern (F6)"),
// … the piano roll, the harmony keys, the mixer
];
for (name, open, picture, about) in windows {
let button = icons::toggle(ui, name, *open, picture);
// ...the hint, and a click opens or closes the window...
}
}
Hay cinco préstamos &mut de self en un array. El borrow checker lo
permite porque cada uno toma prestado un campo distinto:
self.chord_sheet.open, self.rack_open, self.piano_roll.open y así.
Dos &mut al mismo campo no compilarían.
La prueba every_picture_stays_in_its_square pinta cada dibujo en un
cuadrado de 16 puntos y comprueba que ninguna forma se sale de él, y
the_window_buttons_open_and_close_their_windows hace clic en cada uno
de los cinco y comprueba que su ventana se abre, que el botón se
enciende y que un segundo clic la cierra.
Todas las teclas en una lista
Ayuda > Atajos de teclado, o F1, abre una ventana con cada tecla a la que responde la app, sección por sección.
La lista son datos: un Vec de secciones, cada una con un título, una
nota sobre cuándo funcionan sus teclas y sus líneas. Las teclas de una
línea son de una de cuatro clases:
#![allow(unused)]
fn main() {
enum Keys {
/// Any of these does it.
Any(Vec<KeyboardShortcut>),
/// The first does the first half of what the line says, the second the
/// second: "Up / down".
Pair(KeyboardShortcut, KeyboardShortcut),
/// The mouse wheel, turned while holding these.
Wheel(Modifiers),
/// Keys that are not one shortcut, written out.
Written(&'static str),
}
}
Donde la app tiene una constante para un atajo, la lista usa esa misma
constante: files::SAVE, edit::UNDO, piano_roll::OCTAVE_UP,
view::MIXER. Así, si una tecla cambia, la lista cambia con ella.
Convertir unas Keys en texto usa el format_shortcut de egui, que
escribe «Ctrl+Shift+Z» en Linux y Windows y «⇧⌘Z» en un Mac, como lo
escriben los menús.
Como la lista son datos, también se puede probar como datos.
no_two_global_shortcuts_share_a_key reúne las teclas de las secciones
que funcionan en cualquier sitio y falla si alguna tecla aparece dos
veces, porque esa tecla haría dos cosas a la vez.
Ayuda: el manual y Acerca de
Ayuda > Manual de usuario abre las páginas web del manual de usuario
en el idioma de la app. La dirección vive en una función de about.rs,
así que el menú Ayuda y la ventana Acerca de se la piden a ella:
#![allow(unused)]
fn main() {
pub(super) fn manual() -> &'static str {
match i18n::current_language() {
Language::English => MANUAL,
Language::Spanish => MANUAL_ES,
}
}
}
El match no tiene rama _ a propósito. Añade un tercer idioma, y el
compilador se detiene aquí hasta que decidas qué manual reciben sus
lectores. pub(super) hace que manual se vea desde app.rs y desde
los módulos que tiene dentro, como shortcuts.rs, donde está el menú
Ayuda, y desde ningún otro lado.
En el menú, el botón le pide a egui que abra esa dirección en una pestaña nueva del navegador:
#![allow(unused)]
fn main() {
if ui.button(crate::i18n::tr("User manual")).clicked() {
ui.ctx()
.open_url(egui::OpenUrl::new_tab(super::about::manual()));
ui.close();
}
}
Ayuda > Acerca de Ferrous Lineup dice qué versión estás usando, con qué licencia, y dónde encontrar el manual de usuario, este libro (cada uno también en PDF), el código y la lista de problemas conocidos. Es lo primero que hay que mirar antes de informar de uno.
Todo lo que dice sobre el programa sale de Cargo.toml, leído mientras
se compila el programa:
#![allow(unused)]
fn main() {
const ISSUES: &str = concat!(env!("CARGO_PKG_REPOSITORY"), "/issues");
ui.label(format!("{} {}", tr("Version"), env!("CARGO_PKG_VERSION")));
ui.weak(tr(env!("CARGO_PKG_DESCRIPTION")));
}
env!("NOMBRE")es una macro que el compilador sustituye por el valor de la variable de entornoNOMBREmientras compila, como un&'static str. Cargo poneCARGO_PKG_VERSION,CARGO_PKG_DESCRIPTION,CARGO_PKG_REPOSITORYy algunas más a partir de la sección[package]deCargo.toml(la app toma su versión, su licencia y su repositorio del[workspace.package]del workspace). Una variable que no existe es un error de compilación, no una sorpresa para el usuario.concat!junta literales en uno solo en tiempo de compilación, así queISSUEStambién es una constante. Ahí no se podría usarformat!: construye unStringmientras el programa se ejecuta, y unaconsttiene que conocerse antes.
Así, cuando un lanzamiento cambia la versión en Cargo.toml, la ventana
cambia con ella, y nadie tiene que acordarse de un segundo sitio. Las
compilaciones de lanzamiento comparan la
etiqueta con este mismo número.
El dibujo es el mismo que el escritorio muestra para la ventana de la
app: WINDOW_ICON, 256 × 256 píxeles de bytes rojo, verde, azul y alfa,
incluidos en el programa con include_bytes!. egui dibuja las imágenes
a partir de texturas, imágenes guardadas en la tarjeta gráfica, así
que los bytes se convierten en una textura la primera vez que se abre la
ventana y se guardan en la memoria de egui:
#![allow(unused)]
fn main() {
fn icon(ctx: &egui::Context) -> egui::TextureHandle {
let id = egui::Id::unique("about-icon");
if let Some(texture) = ctx.data(|data| data.get_temp(id)) {
return texture;
}
let size = crate::WINDOW_ICON_SIZE as usize;
let image = egui::ColorImage::from_rgba_unmultiplied([size, size], crate::WINDOW_ICON);
let texture = ctx.load_texture("about-icon", image, egui::TextureOptions::LINEAR);
ctx.data_mut(|data| data.insert_temp(id, texture.clone()));
texture
}
}
Un TextureHandle funciona como un Arc: un clon es barato, y la
textura sigue en la tarjeta gráfica mientras viva algún clon. El que se
guarda en la memoria de egui hace que la imagen se envíe a la tarjeta
una sola vez, no en cada frame. WINDOW_ICON es una constante de
main.rs, sin pub; about.rs puede leerla igualmente como
crate::WINDOW_ICON, porque un elemento privado es visible en su propio
módulo y en todos los módulos que hay dentro de él.
ui.hyperlink_to(texto, url) dibuja un enlace. Al pulsarlo no se abre
el navegador directamente: egui añade un OutputCommand::OpenUrl a lo
que devuelve el frame, y eframe pide al sistema que abra la dirección.
Eso hace que el clic sea fácil de probar.
help_about_shows_the_version_the_license_and_where_the_book_is abre
Ayuda > Acerca de Ferrous Lineup, comprueba que la línea de la
versión y cada enlace se dibujan, pulsa El libro y comprueba que el
frame pidió la dirección del libro, sin que se abra ningún navegador.
help_user_manual_opens_the_manual_in_the_apps_language hace lo mismo
con Ayuda > Manual de usuario, en inglés y en español.
Probar una ventana sin pantalla
Todas las pruebas nombradas en este capítulo dibujan la app sin
pantalla. egui::Context::run_ui (o run_pass) toma la entrada de un
frame, la posición del ratón, las teclas y los clics, y devuelve todo lo
que egui le pasaría a la tarjeta gráfica, sin dibujarlo. Una prueba
puede entonces encontrar un widget por su Id o su texto, comprobar
dónde quedó, hacer clic en él en el frame siguiente y leer el estado de
la app. app/ux_tests.rs tiene la mayoría;
Cómo se prueba el código cuenta el resto.
Hay un detalle de estas pruebas que conviene conocer. Lo que devuelve
run_ui incluye la textura de la fuente. egui comprueba, solo en las
compilaciones de depuración, que una textura así no se descarta sin
usarse, y si pasa, entra en pánico. Así que las pruebas que se quedan
con la salida la vacían antes:
#![allow(unused)]
fn main() {
// The font texture is not drawn anywhere here.
output.textures_delta.clear();
}
Una prueba que pasa en una compilación de release y falla en una de
depuración suele ser una de estas comprobaciones: debug_assert! solo
se compila en las compilaciones de depuración, y una librería puede
tener muchas. Por eso la CI ejecuta las pruebas en una compilación de
depuración.