Pestañas de colores, como los grupos de Chrome
Con la barra ya en la fila del título, teñir es lo que hace que un montón de pestañas abiertas se lea de un vistazo: todo lo de un cliente en verde, lo que está en revisión en rojo. Se elige con clic derecho sobre la pestaña ▸ Color, con marca en el que tiene puesto, y "Sin color" para quitarlo. Nueve colores, los mismos que usa Chrome para sus grupos. El color se guarda con la sesión (lista `colors` en estado.json, paralela a `tabs`), así que sobrevive a cerrar la app. Un estado.json viejo sin colores carga igual: la lista entra por serde default y todas las pestañas salen sin teñir. Cómo se pinta: la inactiva lleva el color de fondo translúcido y se le quita el separador vertical (con fondo propio ya se ve dónde acaba). La ACTIVA no se tiñe entera —tiene que seguir fundida con el contenido, que es lo que le da el aspecto de Chrome— sino que lleva el color en una franja de 3 px arriba. En el CSS el color va en componentes RGB sueltos (`--tc: r, g, b`) para sacar de una sola definición tanto el tono sólido como el fondo translúcido, en vez de repetir nueve colores por tema. Al restaurar la sesión, el color se empareja con su URL ANTES de filtrar por origen: el filtro puede descartar entradas y entonces los índices de las dos listas dejarían de casar, y las pestañas saldrían con el color de otra. El id que llega del menú se valida contra la paleta antes de guardarlo; uno desconocido deja la pestaña sin color en vez de escribir cualquier cosa en el disco. Verificado: sintaxis del script con node --check, y en el navegador que la inactiva se tiñe, que la activa conserva su fondo de tarjeta con la franja del color encima, y que el separador desaparece en las teñidas. La compilación la verifica el CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
27
dist/index.html
vendido
27
dist/index.html
vendido
@@ -135,6 +135,32 @@
|
|||||||
right: -8px;
|
right: -8px;
|
||||||
background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px);
|
background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px);
|
||||||
}
|
}
|
||||||
|
/* ── Color de pestaña ────────────────────────────────────────────────────────────
|
||||||
|
Misma idea que los grupos de Chrome: teñir para agrupar de un vistazo («todo lo de
|
||||||
|
este cliente, en verde»). El color se elige con clic derecho sobre la pestaña ▸ Color,
|
||||||
|
y se guarda con la sesión.
|
||||||
|
Va en componentes RGB sueltos (`--tc: r, g, b`) para sacar de UNA sola definición
|
||||||
|
tanto el tono sólido como el fondo translúcido, sin repetir nueve colores por tema.
|
||||||
|
La pestaña ACTIVA no se tiñe entera: tiene que seguir fundida con el contenido, así
|
||||||
|
que lleva el color en una franja superior — igual que Chrome. */
|
||||||
|
.tab[data-color="gris"] { --tc: 95, 99, 104; }
|
||||||
|
.tab[data-color="azul"] { --tc: 26, 115, 232; }
|
||||||
|
.tab[data-color="rojo"] { --tc: 217, 48, 37; }
|
||||||
|
.tab[data-color="amarillo"] { --tc: 249, 171, 0; }
|
||||||
|
.tab[data-color="verde"] { --tc: 30, 142, 62; }
|
||||||
|
.tab[data-color="rosa"] { --tc: 208, 24, 132; }
|
||||||
|
.tab[data-color="morado"] { --tc: 147, 52, 230; }
|
||||||
|
.tab[data-color="cian"] { --tc: 0, 123, 131; }
|
||||||
|
.tab[data-color="naranja"] { --tc: 250, 144, 62; }
|
||||||
|
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
|
||||||
|
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.32); }
|
||||||
|
/* Con fondo propio el separador vertical sobra: ya se ve dónde acaba cada pestaña. */
|
||||||
|
.tab[data-color]:not(.active)::before { display: none; }
|
||||||
|
.tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
|
||||||
|
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
|
||||||
|
}
|
||||||
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
|
/* 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
|
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. */
|
WebKit para que no salga la imagen fantasma al mover una pestaña. */
|
||||||
@@ -417,6 +443,7 @@
|
|||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "tab" + (t.active ? " active" : "");
|
el.className = "tab" + (t.active ? " active" : "");
|
||||||
el.dataset.label = t.label;
|
el.dataset.label = t.label;
|
||||||
|
if (t.color) el.dataset.color = t.color; // teñido desde el menú de la pestaña
|
||||||
el.title = texto;
|
el.title = texto;
|
||||||
|
|
||||||
const fav = document.createElement("span");
|
const fav = document.createElement("span");
|
||||||
|
|||||||
@@ -66,8 +66,25 @@ struct TabRec {
|
|||||||
title: String,
|
title: String,
|
||||||
url: String,
|
url: String,
|
||||||
loading: bool, // la barra pinta un giro en el icono mientras carga
|
loading: bool, // la barra pinta un giro en el icono mientras carga
|
||||||
|
// Color elegido a mano desde el menú de la pestaña (None = sin teñir). Aquí solo vive el
|
||||||
|
// NOMBRE; el tono exacto y cómo se pinta son cosa del CSS de la barra.
|
||||||
|
color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
|
||||||
|
// un vistazo («todo lo de este cliente en verde»). El id viaja en el payload de `ls-tabs`.
|
||||||
|
const TAB_COLORS: &[(&str, &str)] = &[
|
||||||
|
("gris", "Gris"),
|
||||||
|
("azul", "Azul"),
|
||||||
|
("rojo", "Rojo"),
|
||||||
|
("amarillo", "Amarillo"),
|
||||||
|
("verde", "Verde"),
|
||||||
|
("rosa", "Rosa"),
|
||||||
|
("morado", "Morado"),
|
||||||
|
("cian", "Cian"),
|
||||||
|
("naranja", "Naranja"),
|
||||||
|
];
|
||||||
|
|
||||||
struct Shell {
|
struct Shell {
|
||||||
tabs: Vec<TabRec>,
|
tabs: Vec<TabRec>,
|
||||||
active: usize,
|
active: usize,
|
||||||
@@ -107,6 +124,10 @@ struct Persisted {
|
|||||||
win: Option<WinRect>,
|
win: Option<WinRect>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tabs: Vec<String>,
|
tabs: Vec<String>,
|
||||||
|
// Color de cada pestaña, en el mismo orden que `tabs` ("" = sin teñir). Lista aparte para
|
||||||
|
// que un estado.json viejo (sin colores) siga cargando tal cual.
|
||||||
|
#[serde(default)]
|
||||||
|
colors: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
active: usize,
|
active: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -133,6 +154,7 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
zoom_idx: Some(s.zoom_idx),
|
zoom_idx: Some(s.zoom_idx),
|
||||||
win: s.win,
|
win: s.win,
|
||||||
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
|
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
|
||||||
|
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
|
||||||
active: s.active,
|
active: s.active,
|
||||||
autostart: s.autostart,
|
autostart: s.autostart,
|
||||||
}
|
}
|
||||||
@@ -333,7 +355,7 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active }))
|
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
|
||||||
.collect();
|
.collect();
|
||||||
json!({
|
json!({
|
||||||
"tabs": tabs,
|
"tabs": tabs,
|
||||||
@@ -407,7 +429,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
|||||||
let mut s = shell.lock().unwrap();
|
let mut s = shell.lock().unwrap();
|
||||||
let label = format!("tab-{}", s.next_id);
|
let label = format!("tab-{}", s.next_id);
|
||||||
s.next_id += 1;
|
s.next_id += 1;
|
||||||
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true });
|
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None });
|
||||||
label
|
label
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -707,20 +729,35 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
// del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió.
|
// del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió.
|
||||||
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||||||
let Some(win) = main_window(app) else { return };
|
let Some(win) = main_window(app) else { return };
|
||||||
let (idx, total) = {
|
let (idx, total, actual) = {
|
||||||
let shell = app.state::<ShellState>();
|
let shell = app.state::<ShellState>();
|
||||||
let mut s = shell.lock().unwrap();
|
let mut s = shell.lock().unwrap();
|
||||||
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
|
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
|
||||||
s.menu_tab = Some(label.to_string());
|
s.menu_tab = Some(label.to_string());
|
||||||
(i, s.tabs.len())
|
let actual = s.tabs[i].color.clone();
|
||||||
|
(i, s.tabs.len(), actual)
|
||||||
};
|
};
|
||||||
|
// Los ids se arman fuera del builder para poder pasarlos como &str (igual que el resto
|
||||||
|
// del menú) en vez de mezclar String y &str en el mismo constructor.
|
||||||
|
let ids: Vec<String> = TAB_COLORS.iter().map(|(id, _)| format!("tab-color-{id}")).collect();
|
||||||
let build = || -> tauri::Result<Menu<R>> {
|
let build = || -> tauri::Result<Menu<R>> {
|
||||||
|
// Submenú de color, con marca en el que tiene puesto ahora.
|
||||||
|
let mut colores: Vec<CheckMenuItem<R>> = Vec::with_capacity(TAB_COLORS.len() + 1);
|
||||||
|
colores.push(CheckMenuItem::with_id(app, "tab-color-ninguno", "Sin color", true, actual.is_none(), None::<&str>)?);
|
||||||
|
for ((cid, nombre), id) in TAB_COLORS.iter().zip(ids.iter()) {
|
||||||
|
let marcado = actual.as_deref() == Some(*cid);
|
||||||
|
colores.push(CheckMenuItem::with_id(app, id.as_str(), *nombre, true, marcado, None::<&str>)?);
|
||||||
|
}
|
||||||
|
let refs: Vec<&dyn tauri::menu::IsMenuItem<R>> = colores.iter().map(|c| c as &dyn tauri::menu::IsMenuItem<R>).collect();
|
||||||
|
let submenu = Submenu::with_items(app, "Color", true, &refs)?;
|
||||||
Ok(Menu::with_items(
|
Ok(Menu::with_items(
|
||||||
app,
|
app,
|
||||||
&[
|
&[
|
||||||
&MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?,
|
&MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?,
|
||||||
&MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?,
|
&MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?,
|
||||||
&PredefinedMenuItem::separator(app)?,
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&submenu,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
&MenuItem::with_id(app, "tab-close", "Cerrar pestaña", true, None::<&str>)?,
|
&MenuItem::with_id(app, "tab-close", "Cerrar pestaña", true, None::<&str>)?,
|
||||||
&MenuItem::with_id(app, "tab-close-others", "Cerrar las demás", total > 1, None::<&str>)?,
|
&MenuItem::with_id(app, "tab-close-others", "Cerrar las demás", total > 1, None::<&str>)?,
|
||||||
&MenuItem::with_id(app, "tab-close-right", "Cerrar las de la derecha", idx + 1 < total, None::<&str>)?,
|
&MenuItem::with_id(app, "tab-close-right", "Cerrar las de la derecha", idx + 1 < total, None::<&str>)?,
|
||||||
@@ -767,6 +804,24 @@ fn tab_menu_action<R: Runtime>(app: &AppHandle<R>, action: &str) {
|
|||||||
close_tab(app, &l);
|
close_tab(app, &l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Teñir la pestaña (o quitarle el color). Se valida contra la paleta: un id que no
|
||||||
|
// esté en TAB_COLORS deja la pestaña sin color en vez de guardar cualquier cosa.
|
||||||
|
a if a.starts_with("tab-color-") => {
|
||||||
|
let elegido = &a["tab-color-".len()..];
|
||||||
|
let nuevo = TAB_COLORS
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| *id == elegido)
|
||||||
|
.map(|(id, _)| (*id).to_string());
|
||||||
|
{
|
||||||
|
let shell = app.state::<ShellState>();
|
||||||
|
let mut s = shell.lock().unwrap();
|
||||||
|
if let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) {
|
||||||
|
t.color = nuevo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcast(app);
|
||||||
|
save_persisted(app);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1076,6 +1131,7 @@ pub fn run() {
|
|||||||
id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => {
|
id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => {
|
||||||
tab_menu_action(app, id)
|
tab_menu_action(app, id)
|
||||||
}
|
}
|
||||||
|
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
|
||||||
"autostart-toggle" => toggle_autostart(app),
|
"autostart-toggle" => toggle_autostart(app),
|
||||||
"check-update" => {
|
"check-update" => {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
@@ -1101,22 +1157,35 @@ pub fn run() {
|
|||||||
LogicalSize::new(size.width, TAB_BAR_H),
|
LogicalSize::new(size.width, TAB_BAR_H),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Pestañas de la sesión anterior (o una nueva si no hay).
|
// Pestañas de la sesión anterior (o una nueva si no hay), cada una con su color.
|
||||||
let restore: Vec<String> = {
|
// El color se empareja con su URL ANTES de filtrar: el filtro por origen puede
|
||||||
|
// descartar entradas y entonces los índices de las dos listas dejarían de casar.
|
||||||
|
let restore: Vec<(String, String)> = {
|
||||||
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
|
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
|
||||||
saved
|
saved
|
||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|u| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false))
|
.enumerate()
|
||||||
|
.filter(|(_, u)| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false))
|
||||||
.take(12)
|
.take(12)
|
||||||
.cloned()
|
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
if restore.is_empty() {
|
if restore.is_empty() {
|
||||||
create_tab(&handle, None, true);
|
create_tab(&handle, None, true);
|
||||||
} else {
|
} else {
|
||||||
for url in &restore {
|
for (url, color) in &restore {
|
||||||
create_tab(&handle, Some(url.clone()), false);
|
create_tab(&handle, Some(url.clone()), false);
|
||||||
|
if !color.is_empty() {
|
||||||
|
// create_tab acaba de empujar su pestaña 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let idx = saved.active.min(restore.len() - 1);
|
let idx = saved.active.min(restore.len() - 1);
|
||||||
let label = {
|
let label = {
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user