feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "llimphi-workspace"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-workspace — chasis genérico estilo tmux: hospeda N paneles en un árbol BSP (llimphi-widget-panes) con la máquina de estados (split/close/focus/resize) + chrome estándar. La capa sobre la que cualquier app de gioser se monta en un layout intercambiable."
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
llimphi-widget-panes = { path = "../widgets/panes" }
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Demo del chasis `llimphi-workspace`.
|
||||
//!
|
||||
//! Mismo resultado que `panes_demo` pero la app ya no reimplementa la
|
||||
//! máquina de estados: guarda un `Workspace` + un mapa de paneles, y deja
|
||||
//! que el chasis maneje split/cerrar/foco/resize y el chrome. Esto es el
|
||||
//! molde que después adopta cada app de gioser.
|
||||
//!
|
||||
//! Correr: `cargo run -p llimphi-workspace --example workspace_demo --release`
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, FlexDirection, Size, Style},
|
||||
Rect,
|
||||
};
|
||||
use llimphi_ui::{App, Handle, View};
|
||||
use llimphi_theme::Theme;
|
||||
use llimphi_workspace::{workspace_view, Axis, PaneId, Workspace, WorkspacePalette, WsEffect, WsMsg};
|
||||
|
||||
struct Demo;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Msg {
|
||||
Ws(WsMsg),
|
||||
Panel(PaneId, PanelMsg),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum PanelMsg {
|
||||
Inc,
|
||||
Dec,
|
||||
AddNote,
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
Counter(i64),
|
||||
Notes(Vec<String>),
|
||||
}
|
||||
|
||||
struct Model {
|
||||
ws: Workspace,
|
||||
panes: HashMap<PaneId, Kind>,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl App for Demo {
|
||||
type Model = Model;
|
||||
type Msg = Msg;
|
||||
|
||||
fn title() -> &'static str {
|
||||
"workspace — chasis tmux de gioser"
|
||||
}
|
||||
|
||||
fn init(_: &Handle<Msg>) -> Model {
|
||||
let mut ws = Workspace::new(); // panel 0
|
||||
let mut panes = HashMap::new();
|
||||
panes.insert(0, Kind::Counter(0));
|
||||
let id = ws.split(Axis::Horizontal);
|
||||
panes.insert(id, Kind::Notes(vec!["arrastrá el divisor del medio →".into()]));
|
||||
ws.focus(0);
|
||||
Model {
|
||||
ws,
|
||||
panes,
|
||||
theme: Theme::dark(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
|
||||
match msg {
|
||||
Msg::Ws(m) => match model.ws.apply(m) {
|
||||
WsEffect::Created(id) => {
|
||||
// Alternamos tipo para ilustrar paneles heterogéneos.
|
||||
let kind = if id % 2 == 0 {
|
||||
Kind::Counter(0)
|
||||
} else {
|
||||
Kind::Notes(vec![])
|
||||
};
|
||||
model.panes.insert(id, kind);
|
||||
}
|
||||
WsEffect::Closed(id) => {
|
||||
model.panes.remove(&id);
|
||||
}
|
||||
WsEffect::None => {}
|
||||
},
|
||||
Msg::Panel(id, pm) => {
|
||||
if let Some(kind) = model.panes.get_mut(&id) {
|
||||
match (kind, pm) {
|
||||
(Kind::Counter(n), PanelMsg::Inc) => *n += 1,
|
||||
(Kind::Counter(n), PanelMsg::Dec) => *n -= 1,
|
||||
(Kind::Notes(v), PanelMsg::AddNote) => {
|
||||
let n = v.len() + 1;
|
||||
v.push(format!("nota #{n}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
fn view(model: &Model) -> View<Msg> {
|
||||
let palette = WorkspacePalette::from_theme(&model.theme);
|
||||
let panes = &model.panes;
|
||||
let theme = &model.theme;
|
||||
workspace_view(
|
||||
&model.ws,
|
||||
&palette,
|
||||
move |id| render_pane(panes, theme, id),
|
||||
Msg::Ws,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pane(panes: &HashMap<PaneId, Kind>, t: &Theme, id: PaneId) -> View<Msg> {
|
||||
let Some(kind) = panes.get(&id) else {
|
||||
return label("(vacío)".to_string(), 14.0, t.fg_muted);
|
||||
};
|
||||
let body = match kind {
|
||||
Kind::Counter(n) => col(
|
||||
8.0,
|
||||
vec![
|
||||
label(format!("{n}"), 44.0, t.accent),
|
||||
row(
|
||||
8.0,
|
||||
vec![
|
||||
button("−", Msg::Panel(id, PanelMsg::Dec), t),
|
||||
button("+", Msg::Panel(id, PanelMsg::Inc), t),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Kind::Notes(v) => {
|
||||
let mut lines: Vec<View<Msg>> = v
|
||||
.iter()
|
||||
.map(|s| label(format!("• {s}"), 14.0, t.fg_text))
|
||||
.collect();
|
||||
lines.push(button("+ nota", Msg::Panel(id, PanelMsg::AddNote), t));
|
||||
col(6.0, lines)
|
||||
}
|
||||
};
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
gap: Size {
|
||||
width: length(10.0),
|
||||
height: length(10.0),
|
||||
},
|
||||
padding: uniform(12.0),
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![label(format!("panel #{id}"), 13.0, t.fg_muted), body])
|
||||
}
|
||||
|
||||
fn button(text: &str, msg: Msg, t: &Theme) -> View<Msg> {
|
||||
View::new(Style {
|
||||
padding: Rect {
|
||||
left: length(12.0),
|
||||
right: length(12.0),
|
||||
top: length(6.0),
|
||||
bottom: length(6.0),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(t.bg_button)
|
||||
.hover_fill(t.bg_button_hover)
|
||||
.radius(6.0)
|
||||
.on_click(msg)
|
||||
.children(vec![label(text.to_string(), 14.0, t.fg_text)])
|
||||
}
|
||||
|
||||
fn col(gap: f32, children: Vec<View<Msg>>) -> View<Msg> {
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
gap: Size {
|
||||
width: length(gap),
|
||||
height: length(gap),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(children)
|
||||
}
|
||||
|
||||
fn row(gap: f32, children: Vec<View<Msg>>) -> View<Msg> {
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
gap: Size {
|
||||
width: length(gap),
|
||||
height: length(gap),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(children)
|
||||
}
|
||||
|
||||
fn label(text: String, size: f32, color: llimphi_ui::llimphi_raster::peniko::Color) -> View<Msg> {
|
||||
View::new(Style::default()).text(text, size, color)
|
||||
}
|
||||
|
||||
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
|
||||
Rect {
|
||||
left: length(px),
|
||||
right: length(px),
|
||||
top: length(px),
|
||||
bottom: length(px),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
llimphi_ui::run::<Demo>();
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! `llimphi-workspace` — chasis genérico estilo tmux.
|
||||
//!
|
||||
//! Paso 2 de la visión "montar cualquier componente de gioser en un layout
|
||||
//! intercambiable con splits resizables". Donde [`llimphi_widget_panes`]
|
||||
//! aporta el **árbol** (estructura + render + drag), este crate aporta la
|
||||
//! **máquina de estados** (qué panel está enfocado, cómo se parte/cierra,
|
||||
//! el contador de ids) + el **chrome estándar** (toolbar split/cerrar).
|
||||
//!
|
||||
//! ## Cómo lo usa una app
|
||||
//!
|
||||
//! La app guarda un [`Workspace`] en su `Model` y un `HashMap<PaneId, …>`
|
||||
//! con el estado de cada panel. Su `Msg` envuelve dos cosas:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! enum Msg {
|
||||
//! Ws(WsMsg), // mensajes del chasis (focus/split/…)
|
||||
//! Panel(PaneId, PanelMsg), // mensajes de un panel concreto
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! En `update`, los `Ws` se aplican con [`Workspace::apply`], que devuelve
|
||||
//! un [`WsEffect`] indicando si hay que **crear** el estado de un panel
|
||||
//! nuevo o **borrar** el de uno cerrado. En `view`, [`workspace_view`] arma
|
||||
//! el chrome + el árbol; la app sólo provee el contenido de cada hoja (ya
|
||||
//! lifteado a su propio `Msg` — el chasis no toca los `PanelMsg`).
|
||||
//!
|
||||
//! El lift se hace al construir la vista (igual que `shuma-module`), así
|
||||
//! sorteamos la falta de `View::map` sin `Box<dyn Any>`: el chasis es
|
||||
//! genérico sobre el `Msg` del host y nunca downcastea.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
|
||||
Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::View;
|
||||
use llimphi_widget_panes::{panes_view, Layout, PanesPalette};
|
||||
|
||||
pub use llimphi_widget_panes::{Axis, PaneId, Side};
|
||||
|
||||
/// Estado del workspace: el árbol de paneles + cuál está enfocado + el
|
||||
/// contador para asignar ids nuevos. Agnóstico del contenido — el host
|
||||
/// guarda el estado real de cada panel por su `PaneId`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Workspace {
|
||||
layout: Layout,
|
||||
focused: PaneId,
|
||||
next_id: PaneId,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Workspace con un único panel (id `0`).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
layout: Layout::single(0),
|
||||
focused: 0,
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Id del panel enfocado.
|
||||
pub fn focused(&self) -> PaneId {
|
||||
self.focused
|
||||
}
|
||||
|
||||
/// Cantidad de paneles.
|
||||
pub fn count(&self) -> usize {
|
||||
self.layout.count()
|
||||
}
|
||||
|
||||
/// Ids de todos los paneles, en orden espacial.
|
||||
pub fn leaves(&self) -> Vec<PaneId> {
|
||||
self.layout.leaves()
|
||||
}
|
||||
|
||||
/// El árbol crudo (para casos avanzados; lo normal es [`workspace_view`]).
|
||||
pub fn layout(&self) -> &Layout {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
/// Enfoca un panel (no-op si no existe).
|
||||
pub fn focus(&mut self, id: PaneId) {
|
||||
if self.layout.contains(id) {
|
||||
self.focused = id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parte el panel enfocado en `axis`; el nuevo queda enfocado. Devuelve
|
||||
/// el `PaneId` nuevo para que el host cree su estado.
|
||||
pub fn split(&mut self, axis: Axis) -> PaneId {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.layout.split(self.focused, id, axis);
|
||||
self.focused = id;
|
||||
id
|
||||
}
|
||||
|
||||
/// Cierra el panel enfocado (no cierra el último). Devuelve el id
|
||||
/// removido para que el host libere su estado, o `None` si no removió.
|
||||
pub fn close(&mut self) -> Option<PaneId> {
|
||||
if self.count() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let target = self.focused;
|
||||
let (nl, removed) = self.layout.clone().without(target);
|
||||
if removed {
|
||||
self.layout = nl;
|
||||
self.focused = self.layout.first_leaf();
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajusta el ratio del split direccionado por `path`.
|
||||
pub fn resize(&mut self, path: &[Side], delta: f32) {
|
||||
self.layout.resize(path, delta);
|
||||
}
|
||||
|
||||
/// Aplica un mensaje del chasis y reporta el efecto a atender.
|
||||
pub fn apply(&mut self, msg: WsMsg) -> WsEffect {
|
||||
match msg {
|
||||
WsMsg::Focus(id) => {
|
||||
self.focus(id);
|
||||
WsEffect::None
|
||||
}
|
||||
WsMsg::Split(axis) => WsEffect::Created(self.split(axis)),
|
||||
WsMsg::Close => match self.close() {
|
||||
Some(id) => WsEffect::Closed(id),
|
||||
None => WsEffect::None,
|
||||
},
|
||||
WsMsg::Resize(path, d) => {
|
||||
self.resize(&path, d);
|
||||
WsEffect::None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mensajes del chasis. El host los envuelve en su propio `Msg` y los rutea
|
||||
/// a [`Workspace::apply`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WsMsg {
|
||||
Focus(PaneId),
|
||||
Split(Axis),
|
||||
Close,
|
||||
Resize(Vec<Side>, f32),
|
||||
}
|
||||
|
||||
/// Resultado de [`Workspace::apply`] — qué tiene que hacer el host con su
|
||||
/// mapa de estados de panel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WsEffect {
|
||||
/// Nada que hacer.
|
||||
None,
|
||||
/// Se creó un panel nuevo con este id: inicializá su estado.
|
||||
Created(PaneId),
|
||||
/// Se cerró este panel: borrá su estado.
|
||||
Closed(PaneId),
|
||||
}
|
||||
|
||||
/// Paleta del chasis.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WorkspacePalette {
|
||||
pub panes: PanesPalette,
|
||||
pub bar_bg: Color,
|
||||
pub btn_bg: Color,
|
||||
pub btn_hover: Color,
|
||||
pub label: Color,
|
||||
pub muted: Color,
|
||||
}
|
||||
|
||||
impl Default for WorkspacePalette {
|
||||
fn default() -> Self {
|
||||
Self::from_theme(&llimphi_theme::Theme::dark())
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspacePalette {
|
||||
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
|
||||
Self {
|
||||
panes: PanesPalette::from_theme(t),
|
||||
bar_bg: t.bg_panel,
|
||||
btn_bg: t.bg_button,
|
||||
btn_hover: t.bg_button_hover,
|
||||
label: t.fg_text,
|
||||
muted: t.fg_muted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arma el chasis completo: toolbar (Split →/↓, Cerrar, estado) + el árbol
|
||||
/// de paneles.
|
||||
///
|
||||
/// - `leaf` materializa el contenido de cada panel — **ya lifteado al `Msg`
|
||||
/// del host** (el host hace el lift internamente con su `Panel(id, …)`).
|
||||
/// - `lift` mapea los [`WsMsg`] del chasis al `Msg` del host.
|
||||
pub fn workspace_view<Host>(
|
||||
ws: &Workspace,
|
||||
palette: &WorkspacePalette,
|
||||
mut leaf: impl FnMut(PaneId) -> View<Host>,
|
||||
lift: impl Fn(WsMsg) -> Host + Clone + Send + Sync + 'static,
|
||||
) -> View<Host>
|
||||
where
|
||||
Host: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let toolbar = View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
gap: Size {
|
||||
width: length(8.0),
|
||||
height: length(8.0),
|
||||
},
|
||||
padding: uniform(8.0),
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bar_bg)
|
||||
.children(vec![
|
||||
button("Split →", lift(WsMsg::Split(Axis::Horizontal)), palette),
|
||||
button("Split ↓", lift(WsMsg::Split(Axis::Vertical)), palette),
|
||||
button("Cerrar", lift(WsMsg::Close), palette),
|
||||
View::new(Style {
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
}),
|
||||
text(
|
||||
format!("foco #{} · {} paneles", ws.focused(), ws.count()),
|
||||
13.0,
|
||||
palette.muted,
|
||||
),
|
||||
]);
|
||||
|
||||
let lift_resize = lift.clone();
|
||||
let lift_focus = lift.clone();
|
||||
let area = panes_view(
|
||||
ws.layout(),
|
||||
ws.focused(),
|
||||
|id| leaf(id),
|
||||
move |path, phase, d| {
|
||||
let _ = phase;
|
||||
Some((lift_resize)(WsMsg::Resize(path, d)))
|
||||
},
|
||||
move |id| (lift_focus)(WsMsg::Focus(id)),
|
||||
&palette.panes,
|
||||
);
|
||||
|
||||
let area_wrap = View::new(Style {
|
||||
flex_grow: 1.0,
|
||||
size: full(),
|
||||
min_size: zero(),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![area]);
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: full(),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![toolbar, area_wrap])
|
||||
}
|
||||
|
||||
fn button<Host>(label: &str, msg: Host, palette: &WorkspacePalette) -> View<Host>
|
||||
where
|
||||
Host: Clone + Send + Sync + 'static,
|
||||
{
|
||||
View::new(Style {
|
||||
padding: Rect {
|
||||
left: length(12.0),
|
||||
right: length(12.0),
|
||||
top: length(6.0),
|
||||
bottom: length(6.0),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.btn_bg)
|
||||
.hover_fill(palette.btn_hover)
|
||||
.radius(6.0)
|
||||
.on_click(msg)
|
||||
.children(vec![text(label.to_string(), 14.0, palette.label)])
|
||||
}
|
||||
|
||||
fn text<Host>(content: String, size: f32, color: Color) -> View<Host>
|
||||
where
|
||||
Host: Clone + Send + Sync + 'static,
|
||||
{
|
||||
View::new(Style::default()).text(content, size, color)
|
||||
}
|
||||
|
||||
fn full() -> Size<Dimension> {
|
||||
Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
}
|
||||
}
|
||||
|
||||
fn zero() -> Size<Dimension> {
|
||||
Size {
|
||||
width: length(0.0_f32),
|
||||
height: length(0.0_f32),
|
||||
}
|
||||
}
|
||||
|
||||
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
|
||||
Rect {
|
||||
left: length(px),
|
||||
right: length(px),
|
||||
top: length(px),
|
||||
bottom: length(px),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn starts_with_one_pane() {
|
||||
let ws = Workspace::new();
|
||||
assert_eq!(ws.count(), 1);
|
||||
assert_eq!(ws.focused(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_creates_and_focuses_new() {
|
||||
let mut ws = Workspace::new();
|
||||
let id = ws.split(Axis::Horizontal);
|
||||
assert_eq!(ws.count(), 2);
|
||||
assert_eq!(ws.focused(), id);
|
||||
assert_ne!(id, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_split_reports_created() {
|
||||
let mut ws = Workspace::new();
|
||||
match ws.apply(WsMsg::Split(Axis::Vertical)) {
|
||||
WsEffect::Created(id) => assert_eq!(id, ws.focused()),
|
||||
other => panic!("esperaba Created, fue {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_reports_closed_and_refocuses() {
|
||||
let mut ws = Workspace::new();
|
||||
let id = ws.split(Axis::Horizontal); // foco en el nuevo
|
||||
match ws.apply(WsMsg::Close) {
|
||||
WsEffect::Closed(closed) => {
|
||||
assert_eq!(closed, id);
|
||||
assert_eq!(ws.count(), 1);
|
||||
assert_eq!(ws.focused(), 0);
|
||||
}
|
||||
other => panic!("esperaba Closed, fue {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_close_last_pane() {
|
||||
let mut ws = Workspace::new();
|
||||
assert_eq!(ws.apply(WsMsg::Close), WsEffect::None);
|
||||
assert_eq!(ws.count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_ignores_unknown() {
|
||||
let mut ws = Workspace::new();
|
||||
ws.focus(999);
|
||||
assert_eq!(ws.focused(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user