A horizontal swipe commits on a fling; App::overlay_namespace; flat Quick Settings slots in the default theme; the on-screen keyboard's rows twice as far apart, with presses between keys kept; LTK_PERF_RASTER
Flings. A horizontal swipe committed only past `swipe_horizontal_threshold` of the surface width, so a quick flick that stopped short, the usual way to turn a page on a phone, snapped back. The new `input/gesture/fling.rs` holds a `FlingTracker` that measures the finger's speed along one axis over the last 100 ms from the events' own timestamps, so a finger that rested before lifting reads as still. `GestureState` keeps one for the x axis, `fling_x`, cleared on every press; the touch handler feeds it on down, motion and up with the `wl_touch` time, which it used to ignore. On release a horizontal swipe also commits when the finger leaves at 500 logical px/s or more in the direction of travel after at least 24 px, and does not commit when it leaves that fast the other way, whatever the distance; both thresholds scale with the surface. Pointer drags and the vertical swipes keep the distance rule, and the gesture tests, which never feed the tracker, are unchanged. Two tests cover the speed and a rest before lifting. `App::overlay_namespace`. Every layer overlay was created with the namespace `ltk-overlay`, so a compositor could not tell one panel from another. The new method returns the namespace of overlay `id`, read when its surface is created, and `None`, the default, keeps `ltk-overlay`. It follows `overlay_title` and `overlay_opaque_region` and breaks no app. Crustace names its Quick Settings and launcher overlays with it, and forge draws a blurred backdrop behind them. Flat Quick Settings slots. The default theme gains `surface-qs-tile`, `surface-qs-tile-active`, `surface-qs-slider-track` and `surface-qs-slider-fill` in both modes: a solid translucent fill, white or navy for the tiles and tracks and cyan for the active tile and the fill, with no shadows, no inset shadows and no gradients. The glass slots the panel used put a blurred shadow, one to five inset shadows, some with blend modes, and a gradient under each of its nineteen surfaces, all rasterised on every frame of its slide. The shared slots are untouched, so the dock and other applications look as before. The on-screen keyboard. The rows are now `ROW_GAP`, twice `GAP`, apart, 12 logical px against the 6 between keys; `height` counts it, so the panel grows by 18 to 24 px depending on its rows and a host that reserves its height follows. A press between keys, which the wider gap would have made more common, no longer goes nowhere: `key_at` gives it to the nearest key within one row gap. `LTK_PERF_RASTER`. With it set, every GLES raster, full, partial or subsurface, logs its surface size, the time to submit and, after a `glFinish`, the time until the GPU finished. The `glFinish` stalls the pipeline, so it is for measuring only; it gave the cost of the old Quick Settings panel, 27 to 40 ms a raster on the Librem 5 with peaks of 57 to 143 ms.
This commit is contained in:
@@ -561,6 +561,11 @@ pub trait App: 'static
|
||||
/// in the compositor's window lists. Default: the [`app_id`](Self::app_id).
|
||||
fn overlay_title( &self, _id: OverlayId ) -> Option<String> { None }
|
||||
|
||||
/// wlr-layer-shell namespace of a layer overlay, so the compositor can
|
||||
/// tell it apart from the rest. Read when the surface is created.
|
||||
/// Default: `"ltk-overlay"`.
|
||||
fn overlay_namespace( &self, _id: OverlayId ) -> Option<&'static str> { None }
|
||||
|
||||
/// Describe the input-transparent child surfaces composited over the main
|
||||
/// surface this frame. Each becomes a `wl_subsurface` the compositor moves
|
||||
/// by position; see [`SubsurfaceSpec`]. Diffed across frames by
|
||||
|
||||
@@ -25,6 +25,7 @@ use smithay_client_toolkit::compositor::CompositorState;
|
||||
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
|
||||
|
||||
use crate::egl_context::EglContext;
|
||||
use crate::event_loop::perf::RasterTrace;
|
||||
use crate::event_loop::SurfaceState;
|
||||
use crate::render::Canvas;
|
||||
use crate::types::{ Color, Rect };
|
||||
@@ -61,6 +62,7 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
|
||||
{
|
||||
let Some( es ) = ss.egl_surface.as_ref() else { return };
|
||||
if egl_ctx.make_current( es ).is_err() { return; }
|
||||
let trace = RasterTrace::begin();
|
||||
|
||||
let canvas = ss.canvas.get_or_insert_with( ||
|
||||
{
|
||||
@@ -112,6 +114,7 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
|
||||
draw_fallback_banner( canvas, pw, sf );
|
||||
|
||||
canvas.present();
|
||||
if let Some( t ) = trace { t.end( egl_ctx.gl(), "full", pw, ph ); }
|
||||
|
||||
commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
|
||||
ss.content_dirty = false;
|
||||
@@ -152,6 +155,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
|
||||
{
|
||||
let Some( es ) = ss.egl_surface.as_ref() else { return };
|
||||
if egl_ctx.make_current( es ).is_err() { return; }
|
||||
let trace = RasterTrace::begin();
|
||||
|
||||
let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" );
|
||||
if canvas.size() != ( pw, ph )
|
||||
@@ -189,6 +193,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
|
||||
|
||||
canvas.clear_clip();
|
||||
canvas.present();
|
||||
if let Some( t ) = trace { t.end( egl_ctx.gl(), "partial", pw, ph ); }
|
||||
|
||||
commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
|
||||
ss.content_dirty = false;
|
||||
|
||||
@@ -360,7 +360,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
anchor: spec.anchor,
|
||||
size: to_logical_size( resolved_size ),
|
||||
keyboard_exclusive: spec.keyboard_exclusive,
|
||||
namespace: "ltk-overlay",
|
||||
namespace: app.overlay_namespace( spec.id ).unwrap_or( "ltk-overlay" ),
|
||||
};
|
||||
let mut surface = SurfaceKind::Pending( cfg );
|
||||
if let Some( ref output ) = output_opt
|
||||
|
||||
@@ -37,6 +37,40 @@ fn perf_warn_enabled() -> bool
|
||||
)
|
||||
}
|
||||
|
||||
/// Wall time of one GLES raster, split at submission and at GPU completion.
|
||||
/// Opt-in via `LTK_PERF_RASTER`; the `glFinish` it adds stalls the pipeline.
|
||||
pub( crate ) struct RasterTrace
|
||||
{
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl RasterTrace
|
||||
{
|
||||
pub( crate ) fn begin() -> Option<Self>
|
||||
{
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
let enabled = *ENABLED.get_or_init( ||
|
||||
std::env::var( "LTK_PERF_RASTER" )
|
||||
.map( |v| !v.is_empty() && v != "0" )
|
||||
.unwrap_or( false )
|
||||
);
|
||||
enabled.then( || Self { start: Instant::now() } )
|
||||
}
|
||||
|
||||
pub( crate ) fn end( self, gl: &glow::Context, label: &str, pw: u32, ph: u32 )
|
||||
{
|
||||
use glow::HasContext;
|
||||
let submitted = self.start.elapsed();
|
||||
unsafe { gl.finish(); }
|
||||
let done = self.start.elapsed();
|
||||
eprintln!(
|
||||
"[ltk][raster] {label} {pw}x{ph} cpu={:.2}ms gpu_done={:.2}ms",
|
||||
submitted.as_secs_f64() * 1000.0,
|
||||
done.as_secs_f64() * 1000.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-runtime performance-guard state. Lives on `AppData`.
|
||||
pub struct PerfState
|
||||
{
|
||||
|
||||
@@ -395,6 +395,7 @@ fn render_slot_gpu<Msg: Clone>(
|
||||
}
|
||||
let es = slot.egl_surface.as_ref().unwrap();
|
||||
if egl_ctx.make_current( es ).is_err() { return; }
|
||||
let trace = super::perf::RasterTrace::begin();
|
||||
|
||||
// The canvas (and its compiled shader programs) is shared across all
|
||||
// subsurface rasters, so a lazily re-created panel doesn't recompile.
|
||||
@@ -420,6 +421,7 @@ fn render_slot_gpu<Msg: Clone>(
|
||||
let mut ctx = slot_draw_ctx::<Msg>( &mut slot.scroll_canvases, &mut slot.viewport_canvases );
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
canvas.present();
|
||||
if let Some( t ) = trace { t.end( egl_ctx.gl(), "subsurface", pw, ph ); }
|
||||
slot.scroll_canvases = ctx.scroll_canvases;
|
||||
slot.viewport_canvases = ctx.viewport_canvases;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Release velocity of a swipe, so a quick flick commits it whatever its
|
||||
//! travel and a quick flick back cancels it.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Release speed, logical px/s, that decides a swipe on its own.
|
||||
pub const FLING_VELOCITY: f32 = 500.0;
|
||||
/// Travel a forward fling needs, logical px, so a twitch does not commit.
|
||||
pub const FLING_MIN_TRAVEL: f32 = 24.0;
|
||||
|
||||
/// Speed of a finger along one axis over the last `WINDOW_MS` of input,
|
||||
/// from the events' own timestamps. A finger that rested before lifting
|
||||
/// has no samples left in the window and reads as still.
|
||||
#[ derive( Debug, Clone, Default ) ]
|
||||
pub struct FlingTracker
|
||||
{
|
||||
samples: VecDeque<( u32, f32 )>,
|
||||
}
|
||||
|
||||
impl FlingTracker
|
||||
{
|
||||
const WINDOW_MS: u32 = 100;
|
||||
|
||||
pub fn clear( &mut self ) { self.samples.clear(); }
|
||||
|
||||
pub fn push( &mut self, time_ms: u32, pos: f32 )
|
||||
{
|
||||
self.samples.push_back( ( time_ms, pos ) );
|
||||
while let Some( &( t, _ ) ) = self.samples.front()
|
||||
{
|
||||
if time_ms.wrapping_sub( t ) <= Self::WINDOW_MS { break; }
|
||||
self.samples.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Px/s in the unit of the pushed positions; 0 when the window spans
|
||||
/// too little time to tell.
|
||||
pub fn velocity( &self ) -> f32
|
||||
{
|
||||
let ( Some( &( t0, p0 ) ), Some( &( t1, p1 ) ) ) = ( self.samples.front(), self.samples.back() ) else
|
||||
{
|
||||
return 0.0;
|
||||
};
|
||||
let dt = t1.wrapping_sub( t0 );
|
||||
if dt < 8 { return 0.0; }
|
||||
( p1 - p0 ) * 1000.0 / dt as f32
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn measures_recent_speed()
|
||||
{
|
||||
let mut t = FlingTracker::default();
|
||||
t.push( 0, 0.0 );
|
||||
t.push( 20, 10.0 );
|
||||
t.push( 40, 20.0 );
|
||||
assert!( ( t.velocity() - 500.0 ).abs() < 1e-3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn forgets_a_rest_before_lifting()
|
||||
{
|
||||
let mut t = FlingTracker::default();
|
||||
t.push( 0, 0.0 );
|
||||
t.push( 20, 40.0 );
|
||||
t.push( 300, 40.0 );
|
||||
assert_eq!( t.velocity(), 0.0 );
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,10 @@ use crate::tree::{ find_handlers, find_widget, find_widget_at };
|
||||
use crate::types::{ Point, Rect };
|
||||
use crate::widget::LaidOutWidget;
|
||||
|
||||
mod fling;
|
||||
mod outcomes;
|
||||
|
||||
pub( crate ) use fling::FlingTracker;
|
||||
pub( crate ) use outcomes::*;
|
||||
|
||||
#[ cfg( test ) ]
|
||||
@@ -147,6 +149,8 @@ pub struct GestureState<Msg: Clone>
|
||||
/// Pattern-grid node the finger was last over, so `on_node` fires
|
||||
/// once per node rather than once per motion event.
|
||||
pub last_pattern_node: Option<u8>,
|
||||
/// Horizontal release velocity, fed by the touch handler with event times.
|
||||
pub fling_x: FlingTracker,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Default for GestureState<Msg>
|
||||
@@ -180,6 +184,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
overscroll_y: 0.0,
|
||||
mouse_press: false,
|
||||
last_pattern_node: None,
|
||||
fling_x: FlingTracker::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +236,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
self.horizontal_drag_started = false;
|
||||
self.vertical_drag_started = false;
|
||||
self.swipe_axis = None;
|
||||
self.fling_x.clear();
|
||||
self.pressed_idx = hit;
|
||||
// A fresh press resets any stale long-press / source state
|
||||
// from a prior gesture that never released cleanly. The
|
||||
@@ -596,8 +602,14 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
{
|
||||
let dx = pos.x - start.x;
|
||||
let width = swipe.surface_width;
|
||||
let scale = swipe.scale.max( 1.0 );
|
||||
let v = self.fling_x.velocity();
|
||||
let fast = v.abs() >= fling::FLING_VELOCITY * scale;
|
||||
let flung = fast && v.signum() == dx.signum() && dx.abs() >= fling::FLING_MIN_TRAVEL * scale;
|
||||
let flung_back = fast && v.signum() != dx.signum();
|
||||
let committed = width > 0
|
||||
&& dx.abs() >= width as f32 * swipe.horizontal_thresh;
|
||||
&& !flung_back
|
||||
&& ( flung || dx.abs() >= width as f32 * swipe.horizontal_thresh );
|
||||
if committed
|
||||
{
|
||||
self.start = None;
|
||||
|
||||
@@ -53,7 +53,7 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
serial: u32,
|
||||
_time: u32,
|
||||
time: u32,
|
||||
surface: WlSurface,
|
||||
id: i32,
|
||||
position: ( f64, f64 ),
|
||||
@@ -157,6 +157,7 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
let result = ss.gesture.on_press( pos, &ss.frame.widget_rects, &ss.frame.scroll_rects );
|
||||
ss.gesture.fling_x.push( time, pos.x );
|
||||
ss.needs_redraw = true;
|
||||
result
|
||||
};
|
||||
@@ -222,7 +223,7 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_serial: u32,
|
||||
_time: u32,
|
||||
time: u32,
|
||||
id: i32,
|
||||
)
|
||||
{
|
||||
@@ -257,6 +258,8 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
return;
|
||||
}
|
||||
|
||||
let x = self.pointer_pos.x;
|
||||
self.surface_mut( focus ).gesture.fling_x.push( time, x );
|
||||
self.release_primary( focus );
|
||||
}
|
||||
|
||||
@@ -265,7 +268,7 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_time: u32,
|
||||
time: u32,
|
||||
id: i32,
|
||||
position: ( f64, f64 ),
|
||||
)
|
||||
@@ -308,6 +311,7 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
let outcome =
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
ss.gesture.fling_x.push( time, pp.x );
|
||||
ss.gesture.on_move( pp, &ss.frame.widget_rects, &mut ss.frame.scroll_offsets, &swipe, global_drag )
|
||||
};
|
||||
self.apply_move_outcome( focus, outcome );
|
||||
|
||||
+26
-9
@@ -22,6 +22,7 @@ const REPEAT_INTERVAL: Duration = Duration::from_millis( 60 );
|
||||
const SHIFT_DOUBLE_TAP: Duration = Duration::from_millis( 350 );
|
||||
/// Design sizes in logical pixels; the panel multiplies them by its scale.
|
||||
const GAP: f32 = 6.0;
|
||||
const ROW_GAP: f32 = 2.0 * GAP;
|
||||
const PADDING: f32 = 6.0;
|
||||
|
||||
/// What the field being typed into is for; picks the extra row above the layout.
|
||||
@@ -224,7 +225,7 @@ impl Panel
|
||||
pub fn height( &self, width: f32, scale: f32 ) -> f32
|
||||
{
|
||||
let rows = ( self.bar_rows().len() + self.layout().rows( &self.layer ).len() ) as f32;
|
||||
rows * self.key_unit( width, scale ) + ( ( rows - 1.0 ).max( 0.0 ) * GAP + 2.0 * PADDING ) * scale
|
||||
rows * self.key_unit( width, scale ) + ( ( rows - 1.0 ).max( 0.0 ) * ROW_GAP + 2.0 * PADDING ) * scale
|
||||
}
|
||||
|
||||
/// Width of one key unit of the layout's rows, `width` pixels wide: the
|
||||
@@ -246,11 +247,12 @@ impl Panel
|
||||
self.rect = rect;
|
||||
self.scale = scale;
|
||||
self.keys.clear();
|
||||
let gap = GAP * scale;
|
||||
let pad = PADDING * scale;
|
||||
let key_h = self.key_unit( rect.width, scale );
|
||||
let avail = rect.width - 2.0 * pad;
|
||||
let mut y = rect.y + pad;
|
||||
let gap = GAP * scale;
|
||||
let row_gap = ROW_GAP * scale;
|
||||
let pad = PADDING * scale;
|
||||
let key_h = self.key_unit( rect.width, scale );
|
||||
let avail = rect.width - 2.0 * pad;
|
||||
let mut y = rect.y + pad;
|
||||
|
||||
let bar: Vec<Vec<Key>> = self.bar_rows().to_vec();
|
||||
for row in &bar
|
||||
@@ -258,7 +260,7 @@ impl Panel
|
||||
let units: f32 = row.iter().map( |k| k.width ).sum::<f32>().max( 1.0 );
|
||||
let unit = ( avail - gap * row.len().saturating_sub( 1 ) as f32 ) / units;
|
||||
self.push_row( row, rect.x + pad, y, unit, key_h, gap );
|
||||
y += key_h + gap;
|
||||
y += key_h + row_gap;
|
||||
}
|
||||
|
||||
let rows: Vec<Vec<Key>> = self.layout().rows( &self.layer ).iter()
|
||||
@@ -270,7 +272,7 @@ impl Panel
|
||||
let units: f32 = row.iter().map( |k| k.width ).sum();
|
||||
let row_w = units * unit + ( row.len().saturating_sub( 1 ) ) as f32 * gap;
|
||||
self.push_row( row, rect.x + pad + ( avail - row_w ) / 2.0, y, unit, key_h, gap );
|
||||
y += key_h + gap;
|
||||
y += key_h + row_gap;
|
||||
}
|
||||
self.popup = None;
|
||||
}
|
||||
@@ -297,7 +299,22 @@ impl Panel
|
||||
|
||||
fn key_at( &self, pos: Point ) -> Option<usize>
|
||||
{
|
||||
self.keys.iter().position( |k| k.rect.contains( pos ) )
|
||||
if let Some( idx ) = self.keys.iter().position( |k| k.rect.contains( pos ) )
|
||||
{
|
||||
return Some( idx );
|
||||
}
|
||||
let reach = ROW_GAP * self.scale;
|
||||
self.keys.iter()
|
||||
.enumerate()
|
||||
.map( |( i, k )|
|
||||
{
|
||||
let dx = ( k.rect.x - pos.x ).max( pos.x - ( k.rect.x + k.rect.width ) ).max( 0.0 );
|
||||
let dy = ( k.rect.y - pos.y ).max( pos.y - ( k.rect.y + k.rect.height ) ).max( 0.0 );
|
||||
( i, dx.hypot( dy ) )
|
||||
} )
|
||||
.filter( |&( _, d )| d <= reach )
|
||||
.min_by( |a, b| a.1.total_cmp( &b.1 ) )
|
||||
.map( |( i, _ )| i )
|
||||
}
|
||||
|
||||
/// A press at `pos`; `None` when it is outside the panel and the host should treat it as its own.
|
||||
|
||||
@@ -184,6 +184,30 @@
|
||||
"meta": { "semantic": "effect/glass/panel" }
|
||||
},
|
||||
|
||||
"surface-qs-tile": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@white/B3" },
|
||||
"meta": { "semantic": "effect/qs/tile" }
|
||||
},
|
||||
|
||||
"surface-qs-tile-active": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@cyan" },
|
||||
"meta": { "semantic": "effect/qs/tile-active" }
|
||||
},
|
||||
|
||||
"surface-qs-slider-track": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@white/80" },
|
||||
"meta": { "semantic": "effect/qs/slider-track" }
|
||||
},
|
||||
|
||||
"surface-qs-slider-fill": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@cyan" },
|
||||
"meta": { "semantic": "effect/qs/slider-fill" }
|
||||
},
|
||||
|
||||
"surface-card-flat": {
|
||||
"type": "surface",
|
||||
"fill": {
|
||||
@@ -305,6 +329,30 @@
|
||||
"meta": { "semantic": "effect/glass/panel" }
|
||||
},
|
||||
|
||||
"surface-qs-tile": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@navy/B3" },
|
||||
"meta": { "semantic": "effect/qs/tile" }
|
||||
},
|
||||
|
||||
"surface-qs-tile-active": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@cyan" },
|
||||
"meta": { "semantic": "effect/qs/tile-active" }
|
||||
},
|
||||
|
||||
"surface-qs-slider-track": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@navy/80" },
|
||||
"meta": { "semantic": "effect/qs/slider-track" }
|
||||
},
|
||||
|
||||
"surface-qs-slider-fill": {
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "@cyan" },
|
||||
"meta": { "semantic": "effect/qs/slider-fill" }
|
||||
},
|
||||
|
||||
"surface-card-flat": {
|
||||
"type": "surface",
|
||||
"fill": "@surface-glass-dark",
|
||||
|
||||
Reference in New Issue
Block a user