Comparar commits

..

1 Commits

Autor SHA1 Mensaje Fecha
Munka
6539f6939c v1.21.0: la pestaña del color de su página y pestañas con nombre
Para el modo edición de los sets de fotos de Labstream OS, que se abre en su
propia pestaña:

- Color de la página: una página pide el color de su pestaña con
  <html data-ls-pestana="#rrggbb">. El script de la pestaña lo manda en
  ls-title (y lo vigila con el mismo observador del tema); Rust solo acepta
  #rrggbb (color_de_pagina) y la barra pinta la pestaña de ese color, con letra
  clara u oscura según su luminancia. Activa se funde con la barra de arriba de
  la página; detrás conserva el color. Un color del menú encima va de franja.
  Una página con color propio no manda el tema: su fondo oscuro no es el de la
  app y no debe oscurecer la barra.
- Pestañas con nombre: window.open(url, "ls-…") o target="ls-…" abre la
  pestaña la primera vez; las siguientes activan ESA y le pasan la URL por
  window.__lsAbrir (el modo edición cambia de foto sin recargar); sin él,
  navega. Una descartada se revive ya en la URL nueva. Solo nombres ls-
  alfanuméricos (nombre_de_ventana); los demás abren pestaña como siempre.
- Color de página y nombre se guardan con la sesión (paginas, nombres en
  estado.json; un estado viejo carga igual).

Prueba nueva: la_pagina_solo_pide_colores_y_nombres_de_la_app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:33:20 -05:00
Se han modificado 7 ficheros con 177 adiciones y 32 borrados

Ver fichero

@@ -20,6 +20,16 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
- **Colores de pestaña** (v1.6.0): clic derecho sobre una pestaña ▸ **Color**, con
la misma paleta de nueve tonos que usa Chrome para sus grupos. Sirve para agrupar
de un vistazo (todo lo de un cliente en verde) y **se guarda con la sesión**.
- **Pestaña del color de su página** (v1.21.0): una página puede pedir el color de
su pestaña con `<html data-ls-pestana="#rrggbb">` (solo ese formato). El modo
edición de un set la pinta del gris del editor: activa se funde con su barra de
arriba y detrás se reconoce entre las demás. Esa página no cambia el tema de la
barra (su fondo oscuro no es el de la app).
- **Pestañas con nombre** (v1.21.0): `window.open(url, "ls-…")` o un enlace con
`target="ls-…"` abre la pestaña la primera vez y las siguientes **vuelven a
ella** en vez de abrir otra. La página recibe la URL nueva por
`window.__lsAbrir(url)` (el modo edición cambia de foto sin recargar); si no lo
tiene, navega. Nombre y color se guardan con la sesión.
- **Zoom de interfaz**: ⌘/Ctrl `+` / `−` / `0` y Ctrl+rueda, con indicador en la
barra y **persistencia** entre sesiones (50%–250%).
- **Documentos dentro de la app**: los visores/editores del propio servidor

27
dist/index.html vendido
Ver fichero

@@ -7,7 +7,7 @@
<!--
Barra de pestañas del shell de escritorio (webview "chrome", local).
Habla con Rust por eventos (__TAURI__.event):
recibe ls-tabs {tabs:[{label,title,url,loading,active,color}], zoom, version,
recibe ls-tabs {tabs:[{label,title,url,loading,active,color,pagina}], zoom, version,
mac, win, maximized, dark}
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle,
ls-activate-n, ls-nav, ls-menu, ls-check-update, ls-tab-menu, ls-reorder
@@ -208,6 +208,17 @@
}
:root.oscuro .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
:root.oscuro .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
/* ── La pestaña del COLOR DE SU PÁGINA (1.21) ──
La página lo pide (`<html data-ls-pestana="#262626">`): el modo edición de un set pinta la
suya del gris del editor. Activa, es de ese color y se funde con la barra de arriba de la
página, como la de siempre se funde con el blanco de la app. Detrás, el mismo color: entre
las demás se reconoce de un vistazo. Con color del menú encima, ese va de franja arriba.
(#tabs delante: gana a las reglas de tema y de color del menú sin !important.) */
#tabs .tab.pagina { --muted: color-mix(in srgb, var(--tp-fg) 60%, transparent); background: var(--tp); color: var(--tp-fg); }
#tabs .tab.pagina:not(.active) { box-shadow: none; }
#tabs .tab.pagina:not(.active):hover { background: color-mix(in srgb, var(--tp) 86%, var(--tp-fg)); color: var(--tp-fg); }
#tabs .tab.pagina .cerrar:hover { background: color-mix(in srgb, var(--tp-fg) 18%, transparent); color: var(--tp-fg); }
#tabs .tab.pagina[data-color] { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
macOS los eventos no llegan), sino eventos de puntero. Se apaga el arrastre nativo de
WebKit para que no salga la imagen fantasma al mover una pestaña. */
@@ -500,6 +511,14 @@
$tabs.classList.toggle("compactas", ancho < 112);
}
// ¿Un #rrggbb claro? (luminancia relativa): decide si la letra encima va oscura o clara.
function esClaro(hex) {
const n = parseInt(hex.slice(1), 16);
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
return l > 0.35;
}
function render() {
$tabs.textContent = "";
for (const t of estado.tabs) {
@@ -508,6 +527,12 @@
el.className = "tab" + (t.active ? " active" : "");
el.dataset.label = t.label;
if (t.color) el.dataset.color = t.color; // teñido desde el menú de la pestaña
// El color que pide la página (ya validado #rrggbb en el shell), con letra que se lea encima.
if (t.pagina) {
el.classList.add("pagina");
el.style.setProperty("--tp", t.pagina);
el.style.setProperty("--tp-fg", esClaro(t.pagina) ? "#202124" : "#ececec");
}
el.title = texto;
const fav = document.createElement("span");

Ver fichero

@@ -1,6 +1,6 @@
{
"name": "labstream-desktop",
"version": "1.20.0",
"version": "1.21.0",
"private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": {

Ver fichero

@@ -2366,7 +2366,7 @@ dependencies = [
[[package]]
name = "labstream-desktop"
version = "1.20.0"
version = "1.21.0"
dependencies = [
"active-win-pos-rs",
"device_query",

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.20.0"
version = "1.21.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"

Ver fichero

@@ -81,6 +81,19 @@ fn es_pestana_interna(target: &Url, server: &Url) -> bool {
target.origin() == server.origin()
}
// El color que pide una página para su pestaña: solo `#rrggbb` (va a un estilo de la barra; nada
// que no sea un color llega allí).
fn color_de_pagina(c: &str) -> Option<String> {
let hex = c.strip_prefix('#')?;
(hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_ascii_lowercase())
}
// Un nombre de ventana que identifica pestaña: los de la app (`ls-…`, p. ej. `ls-edicion-<set>`).
// Los reservados del navegador (_blank, _self…) y cualquier otro abren pestaña nueva como siempre.
fn nombre_de_ventana(n: &str) -> Option<String> {
(n.starts_with("ls-") && n.len() <= 80 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')).then(|| n.to_string())
}
#[cfg(test)]
mod navigation_tests {
use super::*;
@@ -95,6 +108,20 @@ mod navigation_tests {
assert!(is_google_host("docs.google.com"));
assert!(!is_google_host("docs.google.com.evil.example"));
}
#[test]
fn la_pagina_solo_pide_colores_y_nombres_de_la_app() {
assert_eq!(color_de_pagina("#262626").as_deref(), Some("#262626"));
assert_eq!(color_de_pagina("#A0b1C2").as_deref(), Some("#a0b1c2"));
for malo in ["", "262626", "#26262", "#2626266", "red", "#26262g", "#262626;background:url(x)"] {
assert_eq!(color_de_pagina(malo), None, "{malo}");
}
assert_eq!(nombre_de_ventana("ls-edicion-cmg123").as_deref(), Some("ls-edicion-cmg123"));
for malo in ["", "_blank", "_self", "edicion", "ls-a b", "ls-\"<x>"] {
assert_eq!(nombre_de_ventana(malo), None, "{malo}");
}
assert_eq!(nombre_de_ventana(&format!("ls-{}", "a".repeat(80))), None);
}
}
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
const CHROME: &str = "chrome";
@@ -122,6 +149,12 @@ struct TabRec {
// Ahorro de memoria (memoria.rs): su webview se cerró tras DESCARTE_TRAS dormida. Sigue en la
// barra con título, URL y color, y se crea otra vez al volver a ella.
descartada: bool,
// El color que PIDE la página (`<html data-ls-pestana="#262626">`, ver `color_de_pagina`): el
// modo edición de un set pinta su pestaña del gris del editor. Lo manda la página, no el menú.
pagina: Option<String>,
// El nombre de ventana con que se abrió (`window.open(url, nombre)`): abrir otra vez con el
// mismo nombre lleva a esta pestaña en vez de abrir otra (ver `reusar_pestana`).
nombre: Option<String>,
}
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
@@ -198,6 +231,12 @@ struct Persisted {
// cargar (ver `registrar_diferida`) y la barra necesita algo que enseñar mientras tanto.
#[serde(default)]
titles: Vec<String>,
// El color que pidió la página y el nombre de ventana de cada una (mismo orden; "" = ninguno):
// la pestaña del modo edición vuelve del gris del editor y se sigue reutilizando.
#[serde(default)]
paginas: Vec<String>,
#[serde(default)]
nombres: Vec<String>,
#[serde(default)]
active: usize,
#[serde(default)]
@@ -230,6 +269,8 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
titles: s.tabs.iter().map(|t| t.title.clone()).collect(),
paginas: s.tabs.iter().map(|t| t.pagina.clone().unwrap_or_default()).collect(),
nombres: s.tabs.iter().map(|t| t.nombre.clone().unwrap_or_default()).collect(),
active: s.active,
autostart: s.autostart,
dark: s.dark,
@@ -287,7 +328,9 @@ const INIT_JS_TPL: &str = r#"
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
catch (e) { try { T.opener.openUrl(url); } catch (_) {} }
}
function newTab(url) { emit('ls-new-tab', { url: url }); }
function newTab(url, nombre) { emit('ls-new-tab', nombre ? { url: url, name: nombre } : { url: url }); }
// Una ventana con nombre de la app (`ls-…`): reabrirla lleva a la pestaña que ya la tiene.
function nombreVentana(n) { return typeof n === 'string' && /^ls-[A-Za-z0-9_-]{1,77}$/.test(n) ? n : null; }
// ── Errores de la página → diario de salud del shell (salud.log) ──
// Solo el mensaje, el archivo:línea y la ruta (sin datos del formulario ni de la sesión). Como
@@ -373,13 +416,25 @@ const INIT_JS_TPL: &str = r#"
} catch (e) { return null; }
}
// ── El color que la página pide para SU pestaña ── `<html data-ls-pestana='#262626'>` (el modo
// edición de un set, del gris del editor). "" = ninguno.
function colorPestana() {
try {
var c = document.documentElement && document.documentElement.getAttribute('data-ls-pestana');
return c && /^#[0-9a-fA-F]{6}$/.test(c) ? c : '';
} catch (e) { return ''; }
}
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
// sistema: la barra tiene que copiarle a ELLA.
// sistema: la barra tiene que copiarle a ELLA. Una página con color propio no lo dice: su
// fondo oscuro es el de su pestaña, no el tema de la app.
function report() {
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href, pestana: colorPestana() };
if (!carga.pestana) {
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
}
emit('ls-title', carga);
}
function watchTitle() {
@@ -404,7 +459,7 @@ const INIT_JS_TPL: &str = r#"
// por si el tema aterriza tarde con la hidratación.
function vigilarTema() {
if (!window.MutationObserver) return;
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] };
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-ls-pestana'] };
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
if (document.body) new MutationObserver(report).observe(document.body, conf);
}
@@ -421,7 +476,8 @@ const INIT_JS_TPL: &str = r#"
if (!u) return;
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
var mod = e.metaKey || e.ctrlKey;
if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); }
var nombre = mod ? null : nombreVentana(a.target);
if (isApp(u) && (mod || a.target === '_blank' || nombre)) { e.preventDefault(); newTab(u.href, nombre); }
}, true);
// Clic con la rueda (botón central) en un enlace interno → pestaña nueva, como en el navegador.
@@ -438,7 +494,7 @@ const INIT_JS_TPL: &str = r#"
window.open = function (url, name, feats) {
var u = url ? abs(url) : null;
if (isExt(u)) { openExternal(u.href); return null; }
if (isInterno(u)) { newTab(u.href); return null; }
if (isInterno(u)) { newTab(u.href, nombreVentana(name)); return null; }
return _open ? _open.call(window, url, name, feats) : null;
};
@@ -641,7 +697,7 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
.tabs
.iter()
.enumerate()
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color, "pagina": t.pagina }))
.collect();
json!({
"tabs": tabs,
@@ -814,12 +870,17 @@ fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path:
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
crear_pestana(app, url, activate, None);
}
// Lo mismo, con su nombre de ventana (ver `reusar_pestana`). Devuelve la etiqueta de la nueva.
fn crear_pestana<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool, nombre: Option<String>) -> Option<String> {
let target = url.unwrap_or_else(|| SERVER_URL.to_string());
// Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador.
let Ok(parsed) = Url::parse(&target) else { return };
let Ok(server) = Url::parse(SERVER_URL) else { return };
let parsed = Url::parse(&target).ok()?;
let server = Url::parse(SERVER_URL).ok()?;
if !es_pestana_interna(&parsed, &server) {
return;
return None;
}
let label = {
@@ -827,7 +888,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let mut s = shell.lock().unwrap();
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false, descartada: false });
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false, descartada: false, pagina: None, nombre });
label
};
@@ -839,6 +900,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
broadcast(app);
save_persisted(app);
}
Some(label)
}
Err(e) => {
salud::anotar("ERROR", "pestaña", &format!("no se pudo crear el webview: {e}"));
@@ -846,10 +908,46 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
s.tabs.retain(|t| t.label != label);
None
}
}
}
// ── Pestañas con NOMBRE (1.21) ──
// `window.open(url, "ls-edicion-<set>")` (el modo edición de un set) abre la pestaña la primera
// vez; las siguientes llevan a ESA pestaña, como hace el navegador con una ventana con nombre, en
// vez de amontonar una por foto. A la página se le pasa la URL nueva por `__lsAbrir` (el modo
// edición cambia de foto sin recargar, guardando lo de la que tenía); si no lo conoce, navega.
// Devuelve false si no hay pestaña con ese nombre (entonces se crea).
fn reusar_pestana<R: Runtime>(app: &AppHandle<R>, nombre: &str, url: Option<&str>) -> bool {
let Ok(server) = Url::parse(SERVER_URL) else { return false };
let destino = match url.map(Url::parse) {
None => None,
Some(Ok(u)) if es_pestana_interna(&u, &server) => Some(u),
Some(_) => return false,
};
let (label, descartada) = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let Some(t) = s.tabs.iter_mut().find(|t| t.nombre.as_deref() == Some(nombre)) else { return false };
// Descartada (sin webview): se crea ya en la URL nueva al activarla.
if t.descartada {
if let Some(d) = &destino {
t.url = d.to_string();
}
}
(t.label.clone(), t.descartada)
};
activate_tab(app, &label);
if let (Some(d), false) = (destino, descartada) {
if let Some(wv) = get_webview(app, &label) {
let u = serde_json::to_string(d.as_str()).unwrap_or_else(|_| "\"\"".into());
let _ = wv.eval(format!("(function(u){{try{{if(window.__lsAbrir&&window.__lsAbrir(u))return}}catch(e){{}}location.assign(u)}})({u})"));
}
}
true
}
// Crea el webview de una pestaña: una nueva (create_tab) o una descartada que vuelve
// (revivir_pestana, al activarla). Lo vigila salud.rs: si su proceso muere, se recarga sola.
fn montar_webview<R: Runtime>(app: &AppHandle<R>, label: &str, parsed: Url) -> tauri::Result<Webview<R>> {
@@ -935,7 +1033,7 @@ fn registrar_diferida<R: Runtime>(app: &AppHandle<R>, url: &str, title: &str) {
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
let title = if title.is_empty() { "Labstream OS".to_string() } else { title.to_string() };
s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true });
s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true, pagina: None, nombre: None });
}
// Vuelve a crear el webview de una pestaña descartada, en su última URL. Devuelve false si no se
@@ -1815,7 +1913,7 @@ pub fn run() {
// registran descartadas (título, URL y color en la barra) y se crean al hacer clic en
// ellas, como hacen Chrome y Edge. Antes se cargaban todas a la vez: con 5 pestañas, 5
// copias enteras de la web app arrancando juntas.
let restore: Vec<(String, String, String)> = {
let restore: Vec<(String, String, String, String, String)> = {
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
saved
.tabs
@@ -1824,7 +1922,8 @@ pub fn run() {
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12)
.map(|(i, u)| {
(u.clone(), saved.colors.get(i).cloned().unwrap_or_default(), saved.titles.get(i).cloned().unwrap_or_default())
let de = |v: &Vec<String>| v.get(i).cloned().unwrap_or_default();
(u.clone(), de(&saved.colors), de(&saved.titles), de(&saved.paginas), de(&saved.nombres))
})
.collect()
};
@@ -1832,21 +1931,21 @@ pub fn run() {
create_tab(&handle, None, true);
} else {
let idx = saved.active.min(restore.len() - 1);
for (i, (url, color, title)) in restore.iter().enumerate() {
for (i, (url, color, title, pagina, nombre)) in restore.iter().enumerate() {
if i == idx {
create_tab(&handle, Some(url.clone()), false);
} else {
registrar_diferida(&handle, url, title);
}
if !color.is_empty() {
// La pestaña se acaba de empujar al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut() {
if t.url == *url {
t.color = Some(color.clone());
}
// La pestaña se acaba de empujar al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut().filter(|t| t.url == *url) {
if !color.is_empty() {
t.color = Some(color.clone());
}
t.pagina = color_de_pagina(pagina);
t.nombre = nombre_de_ventana(nombre);
}
}
let label = {
@@ -1881,7 +1980,13 @@ pub fn run() {
let h = handle.clone();
handle.listen_any("ls-new-tab", move |e| {
let url = field(e.payload(), "url");
create_tab(&h, url, true);
let nombre = field(e.payload(), "name").and_then(|n| nombre_de_ventana(&n));
if let Some(n) = &nombre {
if reusar_pestana(&h, n, url.as_deref()) {
return;
}
}
crear_pestana(&h, url, true, nombre);
});
let h = handle.clone();
handle.listen_any("ls-activate", move |e| {
@@ -1982,11 +2087,12 @@ pub fn run() {
});
let h = handle.clone();
handle.listen_any("ls-title", move |e| {
let (label, title, url, dark) = (
let (label, title, url, dark, pestana) = (
field(e.payload(), "label"),
field(e.payload(), "title"),
field(e.payload(), "url"),
field_bool(e.payload(), "dark"),
field(e.payload(), "pestana"),
);
if let Some(label) = label {
let shell = h.state::<ShellState>();
@@ -1998,6 +2104,10 @@ pub fn run() {
if let Some(url) = url {
t.url = url;
}
// (una página de antes de la 1.21 no lo manda: se deja como estaba)
if let Some(p) = pestana {
t.pagina = color_de_pagina(&p);
}
}
// El tema es global (todas las pestañas del origen comparten modo): el
// último reporte manda, venga de la pestaña que venga.

Ver fichero

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Labstream OS",
"version": "1.20.0",
"version": "1.21.0",
"identifier": "co.labstream.os",
"build": {
"frontendDist": "../dist"