mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
tint renderable + ccs + tab focus
This commit is contained in:
@@ -14,10 +14,11 @@ class ButtonsApp(App[str]):
|
|||||||
|
|
||||||
def handle_pressed(self, event: Button.Pressed) -> None:
|
def handle_pressed(self, event: Button.Pressed) -> None:
|
||||||
self.app.bell()
|
self.app.bell()
|
||||||
|
self.log("pressed", event.button.id)
|
||||||
self.exit(event.button.id)
|
self.exit(event.button.id)
|
||||||
|
|
||||||
|
|
||||||
app = ButtonsApp(log="textual.log")
|
app = ButtonsApp(log="textual.log", log_verbosity=2)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
result = app.run()
|
result = app.run()
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ class Timer:
|
|||||||
self._task = get_event_loop().create_task(self._run())
|
self._task = get_event_loop().create_task(self._run())
|
||||||
return self._task
|
return self._task
|
||||||
|
|
||||||
|
def stop_no_wait(self) -> None:
|
||||||
|
"""Stop the timer, and block until it exists."""
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the timer, and block until it exists."""
|
"""Stop the timer, and block until it exists."""
|
||||||
self._task.cancel()
|
self._task.cancel()
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ import warnings
|
|||||||
from asyncio import AbstractEventLoop
|
from asyncio import AbstractEventLoop
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Generic,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
TextIO,
|
||||||
|
Type,
|
||||||
|
TypeVar,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
import rich
|
import rich
|
||||||
import rich.repr
|
import rich.repr
|
||||||
@@ -29,6 +38,7 @@ from ._callback import invoke
|
|||||||
from ._context import active_app
|
from ._context import active_app
|
||||||
from ._event_broker import extract_handler_actions, NoHandler
|
from ._event_broker import extract_handler_actions, NoHandler
|
||||||
from ._profile import timer
|
from ._profile import timer
|
||||||
|
from ._timer import Timer
|
||||||
from .binding import Bindings, NoBinding
|
from .binding import Bindings, NoBinding
|
||||||
from .css.stylesheet import Stylesheet, StylesheetError
|
from .css.stylesheet import Stylesheet, StylesheetError
|
||||||
from .design import ColorSystem
|
from .design import ColorSystem
|
||||||
@@ -163,12 +173,10 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self.css = css
|
self.css = css
|
||||||
|
|
||||||
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
|
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
|
||||||
|
|
||||||
self.registry: set[MessagePump] = set()
|
self.registry: set[MessagePump] = set()
|
||||||
|
|
||||||
self.devtools = DevtoolsClient()
|
self.devtools = DevtoolsClient()
|
||||||
|
|
||||||
self._return_value: ReturnType | None = None
|
self._return_value: ReturnType | None = None
|
||||||
|
self._focus_timer: Timer | None = None
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
@@ -196,6 +204,80 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._return_value = result
|
self._return_value = result
|
||||||
self.close_messages_no_wait()
|
self.close_messages_no_wait()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _focusable_widgets(self) -> list[Widget]:
|
||||||
|
"""Get widgets that may receive focus"""
|
||||||
|
widgets: list[Widget] = []
|
||||||
|
add_widget = widgets.append
|
||||||
|
root = self.screen
|
||||||
|
stack: list[Iterator[Widget]] = [iter(root.children)]
|
||||||
|
pop = stack.pop
|
||||||
|
push = stack.append
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
node = next(stack[-1], None)
|
||||||
|
if node is None:
|
||||||
|
pop()
|
||||||
|
else:
|
||||||
|
if node.is_container:
|
||||||
|
push(iter(node.children))
|
||||||
|
else:
|
||||||
|
if node.can_focus:
|
||||||
|
add_widget(node)
|
||||||
|
|
||||||
|
return widgets
|
||||||
|
|
||||||
|
def show_focus(self) -> None:
|
||||||
|
"""Highlight the currently focused widget."""
|
||||||
|
self.move_focus(0)
|
||||||
|
|
||||||
|
def move_focus(self, direction: int = 0) -> None:
|
||||||
|
"""Move the focus in the given direction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
direction (int, optional): 1 to move forward, -1 to move backward, or
|
||||||
|
0 to highlight the current focus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._focus_timer:
|
||||||
|
self._focus_timer.stop_no_wait()
|
||||||
|
focusable_widgets = self._focusable_widgets
|
||||||
|
|
||||||
|
if not focusable_widgets:
|
||||||
|
# Nothing focusable, so nothing to do
|
||||||
|
return
|
||||||
|
if self.focused is None:
|
||||||
|
# Nothing currently focused, so focus the first one
|
||||||
|
self.set_focus(focusable_widgets[0])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Find the index of the currently focused widget
|
||||||
|
current_index = focusable_widgets.index(self.focused)
|
||||||
|
except ValueError:
|
||||||
|
# Focused widget was removed in the interim, start again
|
||||||
|
self.set_focus(focusable_widgets[0])
|
||||||
|
else:
|
||||||
|
# Only move the focus if we are currently showing the focus
|
||||||
|
if direction and self.has_class("-show-focus"):
|
||||||
|
current_index = (current_index + direction) % len(focusable_widgets)
|
||||||
|
self.set_focus(focusable_widgets[current_index])
|
||||||
|
|
||||||
|
self._focus_timer = self.set_timer(3, self.hide_focus)
|
||||||
|
self.add_class("-show-focus")
|
||||||
|
self.screen.refresh_layout()
|
||||||
|
|
||||||
|
def focus_next(self) -> None:
|
||||||
|
"""Focus the next widget."""
|
||||||
|
self.move_focus(1)
|
||||||
|
|
||||||
|
def focus_previous(self) -> None:
|
||||||
|
"""Focus the previous widget."""
|
||||||
|
self.move_focus(-1)
|
||||||
|
|
||||||
|
def hide_focus(self) -> None:
|
||||||
|
"""Hide the focus."""
|
||||||
|
self.remove_class("-show-focus")
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Yield child widgets for a container."""
|
"""Yield child widgets for a container."""
|
||||||
return
|
return
|
||||||
@@ -405,13 +487,15 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._screen_stack.append(screen)
|
self._screen_stack.append(screen)
|
||||||
return screen
|
return screen
|
||||||
|
|
||||||
async def set_focus(self, widget: Widget | None) -> None:
|
def set_focus(self, widget: Widget | None) -> None:
|
||||||
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
|
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
widget (Widget): [description]
|
widget (Widget): [description]
|
||||||
"""
|
"""
|
||||||
|
self.log("set_focus", widget)
|
||||||
if widget == self.focused:
|
if widget == self.focused:
|
||||||
|
self.log("already focused")
|
||||||
# Widget is already focused
|
# Widget is already focused
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -419,13 +503,13 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
if self.focused is not None:
|
if self.focused is not None:
|
||||||
focused = self.focused
|
focused = self.focused
|
||||||
self.focused = None
|
self.focused = None
|
||||||
await focused.post_message(events.Blur(self))
|
focused.post_message_no_wait(events.Blur(self))
|
||||||
elif widget.can_focus:
|
elif widget.can_focus:
|
||||||
if self.focused is not None:
|
if self.focused is not None:
|
||||||
await self.focused.post_message(events.Blur(self))
|
self.focused.post_message_no_wait(events.Blur(self))
|
||||||
if widget is not None and self.focused != widget:
|
if widget is not None and self.focused != widget:
|
||||||
self.focused = widget
|
self.focused = widget
|
||||||
await widget.post_message(events.Focus(self))
|
widget.post_message_no_wait(events.Focus(self))
|
||||||
|
|
||||||
async def set_mouse_over(self, widget: Widget | None) -> None:
|
async def set_mouse_over(self, widget: Widget | None) -> None:
|
||||||
if widget is None:
|
if widget is None:
|
||||||
@@ -845,7 +929,12 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self.app.refresh()
|
self.app.refresh()
|
||||||
|
|
||||||
async def on_key(self, event: events.Key) -> None:
|
async def on_key(self, event: events.Key) -> None:
|
||||||
await self.press(event.key)
|
if event.key == "tab":
|
||||||
|
self.focus_next()
|
||||||
|
elif event.key == "shift+tab":
|
||||||
|
self.focus_previous()
|
||||||
|
else:
|
||||||
|
await self.press(event.key)
|
||||||
|
|
||||||
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
|
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
|
||||||
log("shutdown request")
|
log("shutdown request")
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ class Color(NamedTuple):
|
|||||||
r, g, b, _a = self
|
r, g, b, _a = self
|
||||||
return (r / 255, g / 255, b / 255)
|
return (r / 255, g / 255, b / 255)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rgb(self) -> tuple[int, int, int]:
|
||||||
|
"""Get just the red, green, and blue components."""
|
||||||
|
r, g, b, _ = self
|
||||||
|
return (r, g, b)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hls(self) -> HLS:
|
def hls(self) -> HLS:
|
||||||
"""Get the color as HLS."""
|
"""Get the color as HLS."""
|
||||||
|
|||||||
@@ -524,10 +524,20 @@ class StylesBuilder:
|
|||||||
def process_color(self, name: str, tokens: list[Token]) -> None:
|
def process_color(self, name: str, tokens: list[Token]) -> None:
|
||||||
"""Processes a simple color declaration."""
|
"""Processes a simple color declaration."""
|
||||||
name = name.replace("-", "_")
|
name = name.replace("-", "_")
|
||||||
|
|
||||||
|
color: Color | None = None
|
||||||
|
alpha: float | None = None
|
||||||
|
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
if token.name in ("color", "token"):
|
if token.name == "scalar":
|
||||||
|
alpha_scalar = Scalar.parse(token.value)
|
||||||
|
if alpha_scalar.unit != Unit.PERCENT:
|
||||||
|
self.error(name, token, "alpha must be given as a percentage.")
|
||||||
|
alpha = alpha_scalar.value / 100.0
|
||||||
|
|
||||||
|
elif token.name in ("color", "token"):
|
||||||
try:
|
try:
|
||||||
self.styles._rules[name] = Color.parse(token.value)
|
color = Color.parse(token.value)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.error(
|
self.error(
|
||||||
name, token, color_property_help_text(name, context="css")
|
name, token, color_property_help_text(name, context="css")
|
||||||
@@ -535,6 +545,12 @@ class StylesBuilder:
|
|||||||
else:
|
else:
|
||||||
self.error(name, token, color_property_help_text(name, context="css"))
|
self.error(name, token, color_property_help_text(name, context="css"))
|
||||||
|
|
||||||
|
if color is not None:
|
||||||
|
if alpha is not None:
|
||||||
|
color = color.with_alpha(alpha)
|
||||||
|
self.styles._rules[name] = color
|
||||||
|
|
||||||
|
process_tint = process_color
|
||||||
process_background = process_color
|
process_background = process_color
|
||||||
process_scrollbar_color = process_color
|
process_scrollbar_color = process_color
|
||||||
process_scrollbar_color_hover = process_color
|
process_scrollbar_color_hover = process_color
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class RulesMap(TypedDict, total=False):
|
|||||||
|
|
||||||
transitions: dict[str, Transition]
|
transitions: dict[str, Transition]
|
||||||
|
|
||||||
|
tint: Color
|
||||||
|
|
||||||
scrollbar_color: Color
|
scrollbar_color: Color
|
||||||
scrollbar_color_hover: Color
|
scrollbar_color_hover: Color
|
||||||
scrollbar_color_active: Color
|
scrollbar_color_active: Color
|
||||||
@@ -156,6 +158,7 @@ class StylesBase(ABC):
|
|||||||
"max_height",
|
"max_height",
|
||||||
"color",
|
"color",
|
||||||
"background",
|
"background",
|
||||||
|
"tint",
|
||||||
"scrollbar_color",
|
"scrollbar_color",
|
||||||
"scrollbar_color_hover",
|
"scrollbar_color_hover",
|
||||||
"scrollbar_color_active",
|
"scrollbar_color_active",
|
||||||
@@ -210,6 +213,7 @@ class StylesBase(ABC):
|
|||||||
|
|
||||||
rich_style = StyleProperty()
|
rich_style = StyleProperty()
|
||||||
|
|
||||||
|
tint = ColorProperty("transparent")
|
||||||
scrollbar_color = ColorProperty("ansi_bright_magenta")
|
scrollbar_color = ColorProperty("ansi_bright_magenta")
|
||||||
scrollbar_color_hover = ColorProperty("ansi_yellow")
|
scrollbar_color_hover = ColorProperty("ansi_yellow")
|
||||||
scrollbar_color_active = ColorProperty("ansi_bright_yellow")
|
scrollbar_color_active = ColorProperty("ansi_bright_yellow")
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
self._classes.update(class_names)
|
self._classes.update(class_names)
|
||||||
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def remove_class(self, *class_names: str) -> None:
|
def remove_class(self, *class_names: str) -> None:
|
||||||
@@ -449,6 +450,7 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
self._classes.difference_update(class_names)
|
self._classes.difference_update(class_names)
|
||||||
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def toggle_class(self, *class_names: str) -> None:
|
def toggle_class(self, *class_names: str) -> None:
|
||||||
|
|||||||
@@ -130,9 +130,14 @@ class MessagePump:
|
|||||||
"""
|
"""
|
||||||
if self._pending_message is None:
|
if self._pending_message is None:
|
||||||
try:
|
try:
|
||||||
self._pending_message = self._message_queue.get_nowait().message
|
message = self._message_queue.get_nowait().message
|
||||||
except QueueEmpty:
|
except QueueEmpty:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
if message is None:
|
||||||
|
self._closed = True
|
||||||
|
raise MessagePumpClosed("The message pump is now closed")
|
||||||
|
self._pending_message = message
|
||||||
|
|
||||||
if self._pending_message is not None:
|
if self._pending_message is not None:
|
||||||
return self._pending_message
|
return self._pending_message
|
||||||
@@ -219,10 +224,12 @@ class MessagePump:
|
|||||||
|
|
||||||
# Combine any pending messages that may supersede this one
|
# Combine any pending messages that may supersede this one
|
||||||
while not (self._closed or self._closing):
|
while not (self._closed or self._closing):
|
||||||
pending = self.peek_message()
|
try:
|
||||||
|
pending = self.peek_message()
|
||||||
|
except MessagePumpClosed:
|
||||||
|
break
|
||||||
if pending is None or not message.can_replace(pending):
|
if pending is None or not message.can_replace(pending):
|
||||||
break
|
break
|
||||||
# self.log(message, "replaced with", pending)
|
|
||||||
try:
|
try:
|
||||||
message = await self.get_message()
|
message = await self.get_message()
|
||||||
except MessagePumpClosed:
|
except MessagePumpClosed:
|
||||||
|
|||||||
47
src/textual/renderables/tint.py
Normal file
47
src/textual/renderables/tint.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
|
||||||
|
from rich.segment import Segment
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
|
from ..color import Color
|
||||||
|
|
||||||
|
|
||||||
|
class Tint:
|
||||||
|
"""Applies a color on top of an existing renderable."""
|
||||||
|
|
||||||
|
def __init__(self, renderable: RenderableType, color: Color) -> None:
|
||||||
|
"""_summary_
|
||||||
|
|
||||||
|
Args:
|
||||||
|
renderable (RenderableType): A renderable.
|
||||||
|
color (Color): A color (presumably with alpha).
|
||||||
|
"""
|
||||||
|
self.renderable = renderable
|
||||||
|
self.color = color
|
||||||
|
|
||||||
|
def __rich_console__(
|
||||||
|
self, console: Console, options: ConsoleOptions
|
||||||
|
) -> RenderResult:
|
||||||
|
segments = console.render(self.renderable, options)
|
||||||
|
|
||||||
|
color = self.color
|
||||||
|
from_rich_color = Color.from_rich_color
|
||||||
|
style_from_color = Style.from_color
|
||||||
|
for segment in segments:
|
||||||
|
text, style, control = segment
|
||||||
|
if control or style is None:
|
||||||
|
yield segment
|
||||||
|
else:
|
||||||
|
yield Segment(
|
||||||
|
text,
|
||||||
|
(
|
||||||
|
style
|
||||||
|
+ style_from_color(
|
||||||
|
(from_rich_color(style.color) + color).rich_color,
|
||||||
|
(from_rich_color(style.bgcolor) + color).rich_color,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
control,
|
||||||
|
)
|
||||||
@@ -199,10 +199,10 @@ class Screen(Widget):
|
|||||||
else:
|
else:
|
||||||
widget, region = self.get_widget_at(event.x, event.y)
|
widget, region = self.get_widget_at(event.x, event.y)
|
||||||
except errors.NoWidget:
|
except errors.NoWidget:
|
||||||
await self.app.set_focus(None)
|
self.app.set_focus(None)
|
||||||
else:
|
else:
|
||||||
if isinstance(event, events.MouseDown) and widget.can_focus:
|
if isinstance(event, events.MouseDown) and widget.can_focus:
|
||||||
await self.app.set_focus(widget)
|
self.app.set_focus(widget)
|
||||||
event.style = self.get_style_at(event.screen_x, event.screen_y)
|
event.style = self.get_style_at(event.screen_x, event.screen_y)
|
||||||
if widget is self:
|
if widget is self:
|
||||||
event.set_forwarded()
|
event.set_forwarded()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from . import messages
|
|||||||
from ._layout import Layout
|
from ._layout import Layout
|
||||||
from .reactive import Reactive, watch
|
from .reactive import Reactive, watch
|
||||||
from .renderables.opacity import Opacity
|
from .renderables.opacity import Opacity
|
||||||
|
from .renderables.tint import Tint
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -466,6 +467,8 @@ class Widget(DOMNode):
|
|||||||
outline=True,
|
outline=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if styles.tint.a != 0:
|
||||||
|
renderable = Tint(renderable, styles.tint)
|
||||||
if styles.opacity != 1.0:
|
if styles.opacity != 1.0:
|
||||||
renderable = Opacity(renderable, opacity=styles.opacity)
|
renderable = Opacity(renderable, opacity=styles.opacity)
|
||||||
|
|
||||||
@@ -665,9 +668,9 @@ class Widget(DOMNode):
|
|||||||
elif self._dirty_regions:
|
elif self._dirty_regions:
|
||||||
self.emit_no_wait(messages.Update(self, self))
|
self.emit_no_wait(messages.Update(self, self))
|
||||||
|
|
||||||
async def focus(self) -> None:
|
def focus(self) -> None:
|
||||||
"""Give input focus to this widget."""
|
"""Give input focus to this widget."""
|
||||||
await self.app.set_focus(self)
|
self.app.set_focus(self)
|
||||||
|
|
||||||
async def capture_mouse(self, capture: bool = True) -> None:
|
async def capture_mouse(self, capture: bool = True) -> None:
|
||||||
"""Capture (or release) the mouse.
|
"""Capture (or release) the mouse.
|
||||||
@@ -713,6 +716,12 @@ class Widget(DOMNode):
|
|||||||
def on_enter(self) -> None:
|
def on_enter(self) -> None:
|
||||||
self.mouse_over = True
|
self.mouse_over = True
|
||||||
|
|
||||||
|
def on_focus(self, event: events.Focus) -> None:
|
||||||
|
self.has_focus = True
|
||||||
|
|
||||||
|
def on_blur(self, event: events.Blur) -> None:
|
||||||
|
self.has_focus = False
|
||||||
|
|
||||||
def on_mouse_scroll_down(self, event) -> None:
|
def on_mouse_scroll_down(self, event) -> None:
|
||||||
if self.is_container:
|
if self.is_container:
|
||||||
self.scroll_down(animate=False)
|
self.scroll_down(animate=False)
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ class Button(Widget, can_focus=True):
|
|||||||
color: $text-primary-darken-2;
|
color: $text-primary-darken-2;
|
||||||
border: tall $primary-lighten-1;
|
border: tall $primary-lighten-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
App.-show-focus Button:focus {
|
||||||
|
tint: $accent 20%;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -73,3 +78,9 @@ class Button(Widget, can_focus=True):
|
|||||||
event.stop()
|
event.stop()
|
||||||
if not self.disabled:
|
if not self.disabled:
|
||||||
await self.emit(Button.Pressed(self))
|
await self.emit(Button.Pressed(self))
|
||||||
|
|
||||||
|
async def on_key(self, event: events.Key) -> None:
|
||||||
|
self.log("BUTTON KEY", event)
|
||||||
|
if event.key == "enter" and not self.disabled:
|
||||||
|
self.log("PRESSEd")
|
||||||
|
await self.emit(Button.Pressed(self))
|
||||||
|
|||||||
Reference in New Issue
Block a user