refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la estructura vieja de eventloop) y suma el 3D: - bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 / accesskit_winit 0.33 / vello_hybrid 0.0.9. - nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido, montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox). - README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif). - excluido modules/allichay (arrastra deps fuera del alcance del front-door). - cargo check --workspace: verde. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,11 +34,11 @@ pub use diagnostics::{Diagnostic, DiagnosticRange, Severity};
|
||||
pub use find::{all_matches, find_next, find_prev, FindState};
|
||||
pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind};
|
||||
pub use ops::{indent_str, EditDelta};
|
||||
pub use state::{ApplyResult, EditorOptions, EditorState};
|
||||
pub use state::{ApplyResult, EditorOptions, EditorState, Preedit};
|
||||
pub use undo::UndoStack;
|
||||
pub use view::{
|
||||
text_editor_view, text_editor_view_full, text_editor_view_highlighted, EditorMetrics,
|
||||
EditorPalette, GutterStyle, PointerEvent,
|
||||
text_editor_view, text_editor_view_colored, text_editor_view_full,
|
||||
text_editor_view_highlighted, EditorMetrics, EditorPalette, GutterStyle, PointerEvent,
|
||||
};
|
||||
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey};
|
||||
use llimphi_ui::{ImeEvent, Key, KeyEvent, KeyState, NamedKey};
|
||||
|
||||
use crate::buffer::Buffer;
|
||||
use crate::clipboard::{Clipboard, NullClipboard};
|
||||
@@ -75,6 +75,12 @@ pub struct EditorState {
|
||||
/// ~40/255 sobre el bg).
|
||||
pub line_tints: Vec<Option<llimphi_ui::llimphi_raster::peniko::Color>>,
|
||||
pub undo: UndoStack,
|
||||
/// Texto en composición del IME (dead keys de acentos, CJK, emoji
|
||||
/// picker). No vive en el buffer hasta el `Commit`: el view lo pinta
|
||||
/// subrayado en el caret y el caret se corre detrás. `None` = sin
|
||||
/// composición activa (el caso normal). Lo administra
|
||||
/// [`Self::apply_ime_event`].
|
||||
pub preedit: Option<Preedit>,
|
||||
/// Línea inicial visible — el viewport renderiza
|
||||
/// `[scroll_offset, scroll_offset + visible)`. El caller llama a
|
||||
/// [`Self::ensure_caret_visible`] tras movimientos para auto-scrollear.
|
||||
@@ -95,6 +101,22 @@ pub struct EditorState {
|
||||
pub highlight_cache: RefCell<Option<HighlightCache>>,
|
||||
}
|
||||
|
||||
/// Texto en composición del IME — el que el método de entrada está
|
||||
/// armando antes de confirmarlo (un acento muerto a medio componer, una
|
||||
/// sílaba CJK con su ventana de candidatos, un emoji del picker). No
|
||||
/// pertenece al buffer todavía; el view lo dibuja en el caret con
|
||||
/// subrayado para que el usuario vea lo que está tecleando.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Preedit {
|
||||
/// Texto provisional a pintar en el caret.
|
||||
pub text: String,
|
||||
/// Rango `(inicio, fin)` en **bytes** dentro de `text` que el IME
|
||||
/// quiere resaltar (la "clause" activa), si lo reporta. Hoy el view
|
||||
/// subraya todo el preedit por igual; el campo se conserva para un
|
||||
/// resaltado más fino cuando haga falta.
|
||||
pub cursor: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Entrada del cache: spans por línea + clave que la generó.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighlightCache {
|
||||
@@ -120,6 +142,7 @@ impl EditorState {
|
||||
guard_lines: Vec::new(),
|
||||
line_tints: Vec::new(),
|
||||
undo: UndoStack::new(),
|
||||
preedit: None,
|
||||
scroll_offset: 0,
|
||||
edit_seq: 0,
|
||||
pending_input_edits: RefCell::new(Vec::new()),
|
||||
@@ -377,6 +400,60 @@ impl EditorState {
|
||||
self.apply_key_with_clipboard(event, &mut NullClipboard)
|
||||
}
|
||||
|
||||
/// Aplica un evento de IME al editor — el camino por el que llegan los
|
||||
/// acentos compuestos (dead keys), CJK y el emoji picker cuando la app
|
||||
/// habilita `App::ime_allowed`. El flujo del IME es
|
||||
/// `Enabled → Preedit* → Commit | Disabled`:
|
||||
///
|
||||
/// - `Enabled`: el IME tomó el foco; no hay nada que insertar aún.
|
||||
/// - `Preedit{text,..}`: texto en composición. Lo guardamos en
|
||||
/// [`Self::preedit`] para que el view lo pinte subrayado en el caret
|
||||
/// **sin** tocar el buffer. Un `Preedit` con `text` vacío cierra la
|
||||
/// composición sin confirmar.
|
||||
/// - `Commit(text)`: texto confirmado. Limpiamos el preedit e
|
||||
/// insertamos `text` en todos los cursores, exactamente como si se
|
||||
/// hubiera tecleado (mismo camino de undo + parseo incremental).
|
||||
/// - `Disabled`: el IME soltó el foco; descartamos cualquier
|
||||
/// composición pendiente.
|
||||
///
|
||||
/// Devuelve [`ApplyResult::Changed`] sólo en el `Commit` no-vacío (lo
|
||||
/// único que persiste); el resto es `CursorMoved` (hubo que
|
||||
/// redibujar el preedit) o `Ignored`.
|
||||
pub fn apply_ime_event(&mut self, event: &ImeEvent) -> ApplyResult {
|
||||
match event {
|
||||
ImeEvent::Enabled => ApplyResult::Ignored,
|
||||
ImeEvent::Preedit { text, cursor } => {
|
||||
let had = self.preedit.is_some();
|
||||
if text.is_empty() {
|
||||
self.preedit = None;
|
||||
if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
|
||||
} else {
|
||||
self.preedit = Some(Preedit { text: text.clone(), cursor: *cursor });
|
||||
ApplyResult::CursorMoved
|
||||
}
|
||||
}
|
||||
ImeEvent::Commit(text) => {
|
||||
let had = self.preedit.take().is_some();
|
||||
if text.is_empty() {
|
||||
return if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored };
|
||||
}
|
||||
let text = text.clone();
|
||||
let changed =
|
||||
self.apply_edit_all(|b, c, _opts| Some(replace_selection(b, c, &text)));
|
||||
if changed {
|
||||
self.bump_edit_seq();
|
||||
ApplyResult::Changed
|
||||
} else {
|
||||
ApplyResult::Ignored
|
||||
}
|
||||
}
|
||||
ImeEvent::Disabled => {
|
||||
let had = self.preedit.take().is_some();
|
||||
if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Como [`Self::apply_key`] pero con backend de clipboard activo:
|
||||
/// Ctrl+C copia la selección, Ctrl+X la corta, Ctrl+V pega lo que
|
||||
/// haya en el clipboard.
|
||||
@@ -1244,6 +1321,61 @@ mod tests {
|
||||
assert!(s.cursor.caret.line != 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_commit_inserta_como_tecleo() {
|
||||
// El caso español: el dead-key compone "á" y llega como Commit.
|
||||
let mut s = EditorState::new();
|
||||
s.apply_key(&evtext("c", false, false));
|
||||
let r = s.apply_ime_event(&ImeEvent::Commit("á".into()));
|
||||
assert_eq!(r, ApplyResult::Changed);
|
||||
assert_eq!(s.text(), "cá");
|
||||
assert!(s.preedit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_preedit_no_toca_el_buffer() {
|
||||
let mut s = EditorState::new();
|
||||
s.set_text("ab");
|
||||
s.cursor = Cursor::at(0, 2);
|
||||
let r = s.apply_ime_event(&ImeEvent::Preedit { text: "´".into(), cursor: None });
|
||||
assert_eq!(r, ApplyResult::CursorMoved);
|
||||
// El buffer sigue intacto; el preedit vive aparte.
|
||||
assert_eq!(s.text(), "ab");
|
||||
assert_eq!(s.preedit.as_ref().map(|p| p.text.as_str()), Some("´"));
|
||||
// El Commit reemplaza la composición e inserta el char final.
|
||||
s.apply_ime_event(&ImeEvent::Commit("é".into()));
|
||||
assert_eq!(s.text(), "abé");
|
||||
assert!(s.preedit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_preedit_vacio_y_disabled_cierran_la_composicion() {
|
||||
let mut s = EditorState::new();
|
||||
s.apply_ime_event(&ImeEvent::Preedit { text: "ㅎ".into(), cursor: None });
|
||||
assert!(s.preedit.is_some());
|
||||
// Un Preedit vacío cancela sin confirmar.
|
||||
let r = s.apply_ime_event(&ImeEvent::Preedit { text: String::new(), cursor: None });
|
||||
assert_eq!(r, ApplyResult::CursorMoved);
|
||||
assert!(s.preedit.is_none());
|
||||
// Disabled sobre algo en composición también limpia.
|
||||
s.apply_ime_event(&ImeEvent::Preedit { text: "ㅎ".into(), cursor: None });
|
||||
s.apply_ime_event(&ImeEvent::Disabled);
|
||||
assert!(s.preedit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_commit_reemplaza_seleccion() {
|
||||
let mut s = EditorState::new();
|
||||
s.set_text("abc");
|
||||
s.cursor = Cursor {
|
||||
anchor: Some(Pos::new(0, 0)),
|
||||
caret: Pos::new(0, 2),
|
||||
desired_col: 2,
|
||||
};
|
||||
s.apply_ime_event(&ImeEvent::Commit("ñ".into()));
|
||||
assert_eq!(s.text(), "ñc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_sin_seleccion_es_ignorado() {
|
||||
use crate::clipboard::MemClipboard;
|
||||
|
||||
@@ -26,6 +26,16 @@ use crate::diagnostics::{Diagnostic, Severity};
|
||||
use crate::highlight::{Language, Span, SyntaxPalette, TokenKind};
|
||||
use crate::state::EditorState;
|
||||
|
||||
/// Tope de líneas que la variante embebida (`text_editor_view_colored`)
|
||||
/// renderiza de una. La virtualización del editor-de-archivos capa a 200 para
|
||||
/// no generar miles de Views (wgpu rechaza el bind group); pero la variante
|
||||
/// embebida deja el scroll al contenedor de afuera y necesita pintar TODAS sus
|
||||
/// líneas (si no, la mitad de abajo queda sin pintar = negro al anclar el panel
|
||||
/// al fondo). Este tope es sólo la red de seguridad de wgpu — el caller acota el
|
||||
/// total real (el shell, por su `MAX_VISIBLE = 400`). Probado: ~400 líneas
|
||||
/// renderizan sin que wgpu rechace nada (el render plano viejo ya lo hacía).
|
||||
pub const EMBEDDED_LINE_CAP: usize = 512;
|
||||
|
||||
/// Paleta del editor. Defaults dark.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EditorPalette {
|
||||
@@ -278,6 +288,7 @@ pub fn text_editor_view_full<Msg: Clone + 'static>(
|
||||
spans,
|
||||
&syntax,
|
||||
match_ranges,
|
||||
None,
|
||||
on_pointer,
|
||||
);
|
||||
|
||||
@@ -291,6 +302,53 @@ pub fn text_editor_view_full<Msg: Clone + 'static>(
|
||||
.children(vec![gutter, content])
|
||||
}
|
||||
|
||||
/// Como [`text_editor_view`] pero el caller provee el color de cada tramo de
|
||||
/// cada línea (`line_color_runs[n]` = `(byte_start, byte_end, Color)` de la
|
||||
/// línea `n`), en vez de derivarlo de un `Language`. Para outputs con coloreo
|
||||
/// semántico propio (un shell que tinta `ls`, paths, urls, números…) sobre el
|
||||
/// mismo editor read-only (numeración + selección + copiar).
|
||||
pub fn text_editor_view_colored<Msg: Clone + 'static>(
|
||||
state: &EditorState,
|
||||
palette: &EditorPalette,
|
||||
metrics: EditorMetrics,
|
||||
visible_lines: usize,
|
||||
line_color_runs: &[Vec<(usize, usize, Color)>],
|
||||
on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
|
||||
) -> View<Msg> {
|
||||
let caret = state.cursor.caret;
|
||||
let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
|
||||
// Variante embebida: el contenedor de afuera (el panel de output del shell)
|
||||
// hace el scroll y reserva alto para TODAS las líneas, así que las pintamos
|
||||
// completas (cap alto = red de seguridad de wgpu, ver `EMBEDDED_LINE_CAP`).
|
||||
let visible = visible_lines.max(1).min(EMBEDDED_LINE_CAP);
|
||||
let line_count = state.line_count();
|
||||
let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
|
||||
let end_line = (scroll + visible).min(line_count);
|
||||
let height = (end_line - scroll) as f32 * metrics.line_height;
|
||||
let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette);
|
||||
let content = build_content(
|
||||
state,
|
||||
palette,
|
||||
metrics,
|
||||
height,
|
||||
scroll,
|
||||
end_line,
|
||||
Vec::new(),
|
||||
&syntax,
|
||||
&[],
|
||||
Some(line_color_runs),
|
||||
on_pointer,
|
||||
);
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size { width: percent(1.0_f32), height: length(height) },
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg)
|
||||
.clip(true)
|
||||
.children(vec![gutter, content])
|
||||
}
|
||||
|
||||
fn build_gutter<Msg: Clone + 'static>(
|
||||
state: &EditorState,
|
||||
scroll: usize,
|
||||
@@ -336,7 +394,8 @@ fn build_gutter<Msg: Clone + 'static>(
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(label, metrics.font_size * 0.85, color, Alignment::End),
|
||||
.text_aligned(label, metrics.font_size * 0.85, color, Alignment::End)
|
||||
.mono(),
|
||||
);
|
||||
}
|
||||
GutterStyle::Phantom => {
|
||||
@@ -394,6 +453,7 @@ fn with_alpha(c: Color, alpha: f32) -> Color {
|
||||
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_content<Msg: Clone + 'static>(
|
||||
state: &EditorState,
|
||||
palette: &EditorPalette,
|
||||
@@ -404,6 +464,11 @@ fn build_content<Msg: Clone + 'static>(
|
||||
spans_per_line: Vec<Vec<Span>>,
|
||||
syntax: &SyntaxPalette,
|
||||
match_ranges: &[(usize, usize)],
|
||||
// Override de color por línea: `color_runs[n]` son `(byte_start, byte_end,
|
||||
// Color)` para la línea `n` del buffer (índice absoluto). Cuando es `Some`,
|
||||
// gana sobre el syntax highlight — para callers que colorean por semántica
|
||||
// propia (un shell que tinta `ls`, paths, urls…). `None` = highlight normal.
|
||||
color_runs: Option<&[Vec<(usize, usize, Color)>]>,
|
||||
on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
|
||||
) -> View<Msg> {
|
||||
let caret = state.cursor.caret;
|
||||
@@ -461,7 +526,9 @@ fn build_content<Msg: Clone + 'static>(
|
||||
children.push(phantom_guard_divider(local_line, metrics, palette));
|
||||
continue;
|
||||
}
|
||||
if let Some(line_spans) = spans_per_line.get(n) {
|
||||
if let Some(runs) = color_runs.and_then(|cr| cr.get(n)) {
|
||||
children.push(line_text_color_runs(local_line, &text, runs, metrics, palette));
|
||||
} else if let Some(line_spans) = spans_per_line.get(n) {
|
||||
children.push(line_text_tokens(local_line, &text, line_spans, metrics, palette, syntax));
|
||||
} else {
|
||||
children.push(line_text_plain(local_line, text, metrics, palette));
|
||||
@@ -473,15 +540,33 @@ fn build_content<Msg: Clone + 'static>(
|
||||
children.extend(diagnostic_underline(d, scroll, end_line, metrics, palette));
|
||||
}
|
||||
|
||||
// 4) Caret — uno por cursor, sólo si visible.
|
||||
// 4) Caret — uno por cursor, sólo si visible. El caret del cursor
|
||||
// primario se corre detrás del preedit del IME en composición (el
|
||||
// texto compuesto se pinta desde `p.col`), para que quede al final
|
||||
// de lo que el usuario está tecleando.
|
||||
let preedit_cols = state.preedit.as_ref().map_or(0, |p| p.text.chars().count());
|
||||
for c in state.all_cursors() {
|
||||
let p = c.caret;
|
||||
if p.line >= scroll && p.line < end_line {
|
||||
let local = crate::cursor::Pos::new(p.line - scroll, p.col);
|
||||
let is_primary = std::ptr::eq(c, &state.cursor);
|
||||
let col = if is_primary { p.col + preedit_cols } else { p.col };
|
||||
let local = crate::cursor::Pos::new(p.line - scroll, col);
|
||||
children.push(caret_rect(local, metrics, palette));
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Preedit del IME — texto en composición pintado en el caret con
|
||||
// subrayado, todavía fuera del buffer. Sólo en el cursor primario y
|
||||
// si su línea está en viewport. (En mono el ancho es exacto; el
|
||||
// texto que sigue al caret puede solaparse mientras se compone —
|
||||
// transitorio y, en el caso típico de acentos, de un solo char.)
|
||||
if let Some(pre) = state.preedit.as_ref() {
|
||||
let p = state.cursor.caret;
|
||||
if p.line >= scroll && p.line < end_line {
|
||||
children.extend(preedit_views(p.line - scroll, p.col, &pre.text, metrics, palette));
|
||||
}
|
||||
}
|
||||
|
||||
let click_cb = on_pointer.clone();
|
||||
let drag_cb = on_pointer;
|
||||
View::new(Style {
|
||||
@@ -603,6 +688,7 @@ fn line_text_plain<Msg: Clone + 'static>(
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start)
|
||||
.mono()
|
||||
}
|
||||
|
||||
/// Renderiza una línea como secuencia de Views absolutos posicionados,
|
||||
@@ -660,6 +746,38 @@ fn line_text_tokens<Msg: Clone + 'static>(
|
||||
runs,
|
||||
Alignment::Start,
|
||||
)
|
||||
.mono()
|
||||
}
|
||||
|
||||
/// Como [`line_text_tokens`] pero con `(byte_start, byte_end, Color)`
|
||||
/// explícitos provistos por el caller (coloreo semántico propio, p. ej. un
|
||||
/// shell que tinta `ls`/paths/urls). El resto del texto va en `fg_text`.
|
||||
fn line_text_color_runs<Msg: Clone + 'static>(
|
||||
line: usize,
|
||||
text: &str,
|
||||
runs: &[(usize, usize, Color)],
|
||||
metrics: EditorMetrics,
|
||||
palette: &EditorPalette,
|
||||
) -> View<Msg> {
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(4.0_f32),
|
||||
top: length(line as f32 * metrics.line_height),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
|
||||
..Default::default()
|
||||
})
|
||||
.text_runs(
|
||||
text.to_string(),
|
||||
metrics.font_size,
|
||||
palette.fg_text,
|
||||
runs.to_vec(),
|
||||
Alignment::Start,
|
||||
)
|
||||
.mono()
|
||||
}
|
||||
|
||||
fn caret_rect<Msg: Clone + 'static>(
|
||||
@@ -683,6 +801,52 @@ fn caret_rect<Msg: Clone + 'static>(
|
||||
.fill(palette.caret)
|
||||
}
|
||||
|
||||
/// Pinta el texto en composición del IME en `(local_line, col)`: el texto
|
||||
/// provisional + un subrayado debajo que lo marca como no-confirmado.
|
||||
/// Devuelve los dos Views (texto y subrayado). Posición en coords del
|
||||
/// área de contenido (mismo origen que [`line_text_plain`]).
|
||||
fn preedit_views<Msg: Clone + 'static>(
|
||||
local_line: usize,
|
||||
col: usize,
|
||||
text: &str,
|
||||
metrics: EditorMetrics,
|
||||
palette: &EditorPalette,
|
||||
) -> Vec<View<Msg>> {
|
||||
let x = 4.0 + col as f32 * metrics.char_width;
|
||||
let y = local_line as f32 * metrics.line_height;
|
||||
let w = (text.chars().count() as f32 * metrics.char_width).max(metrics.char_width);
|
||||
vec![
|
||||
// Texto provisional, en el color de texto normal.
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(x),
|
||||
top: length(y),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size { width: length(w), height: length(metrics.line_height) },
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(text.to_string(), metrics.font_size, palette.fg_text, Alignment::Start)
|
||||
.mono(),
|
||||
// Subrayado: una línea fina en el color del caret bajo el texto.
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(x),
|
||||
top: length(y + metrics.line_height - 2.0),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size { width: length(w), height: length(1.5_f32) },
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.caret),
|
||||
]
|
||||
}
|
||||
|
||||
fn bracket_highlight<Msg: Clone + 'static>(
|
||||
pos: Pos,
|
||||
metrics: EditorMetrics,
|
||||
|
||||
Reference in New Issue
Block a user