components: app-owned text input and menu; use-import style
oakui/component gains the two app-facing components: text_input: the app's text field — the gpui_elements editing engine (IME composition, caret, selection, undo) wrapped with the app theme's colors (text via a text_color refinement on the wrapping div so the engine's run layout picks it up; selection/caret/placeholder/IME-marked directly) — and install_text_input_bindings(), which binds the Backspace/Delete/arrow/Home/End/select-all editing keys into the app keymap scoped to the EditableText context. The app never installed them before, so every field accepted IME text but ignored its editing keys; the bindings are now wired once at OakApp::new. All seven call sites (ofx_params x2, effect_library, manager, dialogs x3) use the component instead of gpui_elements directly. menu: all app menu code consolidates here — the model types are re-exported, the shared context-menu plumbing (ContextMenuHandle, ContextMenuTriggered) and the shared segments (edit/clip-edit/in-out/ color-label/new, the viewer context menu, the dynamic language menu) move in from src/menus, which is deleted; app.rs and every panel import from the component. Inline fully-qualified crate paths in non-use positions are replaced with use imports.
This commit is contained in:
+2
-1
@@ -826,7 +826,8 @@ mod tests {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
|
||||
fn collect(menu: &gpui_widgets::menu::Menu, out: &mut Vec<usize>) {
|
||||
use crate::oakui::component::menu;
|
||||
fn collect(menu: &menu::Menu, out: &mut Vec<usize>) {
|
||||
for item in &menu.items {
|
||||
out.push(item.id);
|
||||
if let Some(sub) = &item.submenu {
|
||||
|
||||
+17
-12
@@ -53,7 +53,8 @@ use gpui::{
|
||||
use gpui_widgets::audio_meter::{AudioLevelMeter, MeterOrientation};
|
||||
use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent};
|
||||
use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions};
|
||||
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
|
||||
use crate::oakui::component::menu::{self, Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
|
||||
use crate::oakui::component::text_input::install_text_input_bindings;
|
||||
use gpui_widgets::theme::{apply_theme, OakTheme};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
@@ -417,6 +418,10 @@ impl<E: AppEngine> OakApp<E> {
|
||||
// the shell's `on_action` listeners — the same path the menu clicks
|
||||
// take through `on_menu`.
|
||||
cx.bind_keys(crate::actions::key_bindings());
|
||||
// The text-input editing keys (Backspace/Delete/arrows/Home/End/
|
||||
// select-all …) are scoped to the `EditableText` key context; without
|
||||
// them every field accepts IME text but ignores its editing keys.
|
||||
install_text_input_bindings(cx);
|
||||
|
||||
// --- dock ----------------------------------------------------------
|
||||
let dock = cx.new(|cx| {
|
||||
@@ -763,7 +768,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// through the same path the keyboard shortcuts use, so a menu click
|
||||
/// and a key press can never diverge.
|
||||
fn on_menu(&mut self, item: usize, cx: &mut Context<Self>) {
|
||||
if let Some(index) = crate::menus::shared::language_item_index(item) {
|
||||
if let Some(index) = menu::language_item_index(item) {
|
||||
let languages = crate::i18n::available_languages();
|
||||
if let Some(code) = languages.get(index) {
|
||||
let code = code.clone();
|
||||
@@ -784,11 +789,11 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// `PanelEvent::Focused`).
|
||||
fn wire_panel_context_menu<P>(cx: &mut Context<Self>, panel: &Entity<P>, id: PanelId)
|
||||
where
|
||||
P: gpui::EventEmitter<crate::menus::context::ContextMenuTriggered>,
|
||||
P: gpui::EventEmitter<menu::ContextMenuTriggered>,
|
||||
{
|
||||
cx.subscribe(
|
||||
panel,
|
||||
move |this, _panel, event: &crate::menus::context::ContextMenuTriggered, cx| {
|
||||
move |this, _panel, event: &menu::ContextMenuTriggered, cx| {
|
||||
this.focused_panel = Some(id);
|
||||
this.on_menu(event.item, cx);
|
||||
},
|
||||
@@ -2346,10 +2351,10 @@ impl MenuState {
|
||||
}
|
||||
|
||||
/// One menu item straight from the registry — delegates to
|
||||
/// [`menus::shared::action_item`](crate::menus::shared::action_item) so the
|
||||
/// [`menu::action_item`](menu::action_item) so the
|
||||
/// menu bar and the context menus build items the same way.
|
||||
fn menu_item(action: ActionId) -> MenuItem {
|
||||
crate::menus::shared::action_item(action)
|
||||
menu::action_item(action)
|
||||
}
|
||||
|
||||
/// Builds the menu bar entries (文件/编辑/视图/回放/序列/窗口/工具/帮助) from
|
||||
@@ -2478,7 +2483,7 @@ fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
|
||||
tr("menu.view"),
|
||||
Menu::new(vec![
|
||||
menu_item(A::ThemeDark).with_submenu(theme_submenu),
|
||||
crate::menus::shared::language_menu(),
|
||||
menu::language_menu(),
|
||||
menu_item(A::ZoomIn).separated(),
|
||||
menu_item(A::ZoomOut),
|
||||
menu_item(A::IncreaseTrackHeight),
|
||||
@@ -2786,12 +2791,12 @@ mod tests {
|
||||
let zh = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + zh_index)
|
||||
.find(|i| i.id == menu::LANG_ITEM_BASE + zh_index)
|
||||
.expect("zh item");
|
||||
let en = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + en_index)
|
||||
.find(|i| i.id == menu::LANG_ITEM_BASE + en_index)
|
||||
.expect("en item");
|
||||
assert_eq!(zh.label, "简体中文 (zh-CN)");
|
||||
assert_eq!(en.label, "English (en-US)");
|
||||
@@ -2803,12 +2808,12 @@ mod tests {
|
||||
let zh = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + zh_index)
|
||||
.find(|i| i.id == menu::LANG_ITEM_BASE + zh_index)
|
||||
.expect("zh item");
|
||||
let en = submenu
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == crate::menus::shared::LANG_ITEM_BASE + en_index)
|
||||
.find(|i| i.id == menu::LANG_ITEM_BASE + en_index)
|
||||
.expect("en item");
|
||||
assert_eq!(zh.checked, Some(true), "zh-CN is active → checked");
|
||||
assert_eq!(en.checked, Some(false));
|
||||
@@ -2826,7 +2831,7 @@ mod tests {
|
||||
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let dark_item = |dark: bool| -> gpui_widgets::menu::MenuItem {
|
||||
let dark_item = |dark: bool| -> menu::MenuItem {
|
||||
let entries = make_menus(MenuState::new(dark));
|
||||
let view = entries
|
||||
.iter()
|
||||
|
||||
+5
-4
@@ -31,12 +31,13 @@ use gpui::{
|
||||
div, px, App, Context, ElementId, Entity, EventEmitter, Focusable, FocusHandle, Keystroke,
|
||||
PathPromptOptions, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage, TextChanged};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage, TextChanged};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
|
||||
use gpui_widgets::slider::SliderModel;
|
||||
use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent};
|
||||
use gpui_widgets::value::ValueKind;
|
||||
use crate::oakui::component::text_input;
|
||||
|
||||
use crate::actions::ActionId;
|
||||
use crate::i18n;
|
||||
@@ -748,7 +749,7 @@ impl Render for PathField {
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(
|
||||
text_input("gpui-widgets-export-path")
|
||||
text_input("gpui-widgets-export-path", cx)
|
||||
.state(weak)
|
||||
.accepts_input(true),
|
||||
)
|
||||
@@ -1720,7 +1721,7 @@ impl Render for KeyboardTabContent {
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(text_input("keyboard-search-input").state(weak).accepts_input(true));
|
||||
.child(text_input("keyboard-search-input", cx).state(weak).accepts_input(true));
|
||||
|
||||
// The grouped, filtered action list.
|
||||
let mut list = div()
|
||||
@@ -2221,7 +2222,7 @@ impl Render for ActionSearchContent {
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(text_input("action-search-input").state(weak).accepts_input(true)),
|
||||
.child(text_input("action-search-input", cx).state(weak).accepts_input(true)),
|
||||
)
|
||||
.child(list)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ pub mod app;
|
||||
pub mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod manager;
|
||||
pub mod menus;
|
||||
pub mod oakui;
|
||||
pub mod panels;
|
||||
|
||||
|
||||
+3
-2
@@ -36,7 +36,8 @@ use gpui::{
|
||||
div, App, ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, Render, SharedString,
|
||||
Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage};
|
||||
use crate::oakui::component::text_input;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::{AppEngine, LibraryProject};
|
||||
@@ -438,7 +439,7 @@ impl Render for NamePrompt {
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(
|
||||
text_input("gpui-widgets-rename-field")
|
||||
text_input("gpui-widgets-rename-field", cx)
|
||||
.state(weak)
|
||||
.accepts_input(true),
|
||||
),
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The context-menu plumbing every panel shares: each panel owns one
|
||||
//! [`ContextMenuHandle`], which wraps the
|
||||
//! [`ContextMenu`](gpui_widgets::menu::ContextMenu) popup entity and splits
|
||||
//! its item activations in two — items whose id belongs to the action
|
||||
//! registry ([`crate::actions::entry_for_menu_id`]) are re-emitted as
|
||||
//! [`ContextMenuTriggered`] so the app shell routes them through the same
|
||||
//! dispatch path the menu bar uses, and everything else (the local ids from
|
||||
//! [`super::shared::LOCAL_ID_BASE`] up) goes to the panel's own handler.
|
||||
//! This is the Rust counterpart of the C++ panels wiring shared
|
||||
//! `MenuShared` actions and widget-local slots into one `QMenu`.
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Pixels, Point, Window};
|
||||
use gpui_widgets::menu::{ContextMenu, ContextMenuEvent, Menu};
|
||||
|
||||
/// A registry-backed context-menu item was triggered: the panel re-emits it
|
||||
/// so the app shell dispatches it like a menu-bar click (after pointing
|
||||
/// `focused_panel` at the panel, since a right-click does not emit
|
||||
/// `PanelEvent::Focused`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ContextMenuTriggered {
|
||||
/// The triggered item's id (an action registry menu id).
|
||||
pub item: usize,
|
||||
}
|
||||
|
||||
/// A panel's context menu: owns the popup entity, re-emits registry items as
|
||||
/// [`ContextMenuTriggered`] and hands local items to the panel.
|
||||
pub struct ContextMenuHandle {
|
||||
menu: Entity<ContextMenu>,
|
||||
}
|
||||
|
||||
impl ContextMenuHandle {
|
||||
/// Create the popup entity and subscribe to it. `on_local_item` handles
|
||||
/// every triggered item that is not in the action registry (color
|
||||
/// labels, panel-specific placeholders, …).
|
||||
pub fn new<P, F>(on_local_item: F, window: &mut Window, cx: &mut Context<P>) -> Self
|
||||
where
|
||||
P: EventEmitter<ContextMenuTriggered>,
|
||||
F: Fn(&mut P, usize, &mut Context<P>) + 'static,
|
||||
{
|
||||
let menu = cx.new(|cx| ContextMenu::new(0, window, cx));
|
||||
cx.subscribe(
|
||||
&menu,
|
||||
move |panel: &mut P, _menu, event: &ContextMenuEvent, cx| {
|
||||
if crate::actions::entry_for_menu_id(event.item).is_some() {
|
||||
cx.emit(ContextMenuTriggered { item: event.item });
|
||||
} else {
|
||||
on_local_item(panel, event.item, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
Self { menu }
|
||||
}
|
||||
|
||||
/// Open the menu at `position` (window coordinates).
|
||||
pub fn show(&self, position: Point<Pixels>, menu: Menu, cx: &mut App) {
|
||||
self.menu.update(cx, |menu_view, cx| menu_view.show(position, menu, cx));
|
||||
}
|
||||
|
||||
/// The popup entity, to be rendered as a child of the panel so the
|
||||
/// anchored popup can paint above it.
|
||||
pub fn widget(&self) -> Entity<ContextMenu> {
|
||||
self.menu.clone()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The right-click menu layer: the Rust counterpart of the C++ context-menu
|
||||
//! system (`MenuShared` segments + the per-widget `show_context_menu`
|
||||
//! methods, `app/widget/menu/menushared.cpp` and friends).
|
||||
//!
|
||||
//! * [`context`] — the shared plumbing every panel needs to own a
|
||||
//! [`ContextMenu`](gpui_widgets::menu::ContextMenu): entity creation,
|
||||
//! event subscription and show/hide, plus the
|
||||
//! [`ContextMenuTriggered`](context::ContextMenuTriggered) event the
|
||||
//! panels emit so the shell routes registry items through the same
|
||||
//! dispatch path the menu bar uses.
|
||||
//! * [`shared`] — the shared menu segments (edit / clip-edit / in-out /
|
||||
//! color label / new), built from the action registry
|
||||
//! ([`crate::actions`]) so ids, labels and shortcut annotations can never
|
||||
//! diverge from the menu bar.
|
||||
|
||||
pub mod context;
|
||||
pub mod shared;
|
||||
@@ -14,19 +14,86 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The shared context-menu segments: the Rust counterpart of the C++
|
||||
//! `MenuShared` item groups (`add_items_for_edit_menu`,
|
||||
//! `add_items_for_clip_edit_menu`, `add_items_for_in_out_menu`,
|
||||
//! `add_items_for_new_menu` and the `ColorLabelMenu`,
|
||||
//! `app/widget/menu/menushared.cpp` + `app/widget/colorlabelmenu/`).
|
||||
//! The app's menu component: the menu bar and the context menus.
|
||||
//!
|
||||
//! Registry-backed items are built from [`crate::actions`] exactly like the
|
||||
//! menu bar, so ids, labels and shortcut annotations can never diverge.
|
||||
//! Local (non-registry) items — the 16 color labels today — live at
|
||||
//! [`LOCAL_ID_BASE`] and above; [`crate::actions::entry_for_menu_id`] is
|
||||
//! what splits the two worlds at dispatch time.
|
||||
//! All app menu code lives here instead of reaching into gpui_widgets
|
||||
//! directly: the rendering-engine types are re-exported
|
||||
//! ([`Menu`], [`MenuItem`], [`MenuBar`], …), the shared context-menu
|
||||
//! plumbing ([`ContextMenuHandle`], [`ContextMenuTriggered`]) and the
|
||||
//! shared menu segments (edit / clip-edit / in-out / color label / new,
|
||||
//! plus the viewer context menu) are built from the action registry
|
||||
//! ([`crate::actions`]) exactly like the menu bar, so ids, labels and
|
||||
//! shortcut annotations can never diverge.
|
||||
//!
|
||||
//! Local (non-registry) items — the color labels and the dynamic
|
||||
//! language items — live at [`LOCAL_ID_BASE`] and above;
|
||||
//! [`crate::actions::entry_for_menu_id`] splits the two worlds at
|
||||
//! dispatch time.
|
||||
|
||||
use gpui_widgets::menu::{Menu, MenuItem};
|
||||
pub use gpui_widgets::menu::{
|
||||
ContextMenu, ContextMenuEvent, Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context-menu plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Pixels, Point, Window};
|
||||
/// A registry-backed context-menu item was triggered: the panel re-emits it
|
||||
/// so the app shell dispatches it like a menu-bar click (after pointing
|
||||
/// `focused_panel` at the panel, since a right-click does not emit
|
||||
/// `PanelEvent::Focused`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ContextMenuTriggered {
|
||||
/// The triggered item's id (an action registry menu id).
|
||||
pub item: usize,
|
||||
}
|
||||
|
||||
/// A panel's context menu: owns the popup entity, re-emits registry items as
|
||||
/// [`ContextMenuTriggered`] and hands local items to the panel.
|
||||
pub struct ContextMenuHandle {
|
||||
menu: Entity<ContextMenu>,
|
||||
}
|
||||
|
||||
impl ContextMenuHandle {
|
||||
/// Create the popup entity and subscribe to it. `on_local_item` handles
|
||||
/// every triggered item that is not in the action registry (color
|
||||
/// labels, panel-specific placeholders, …).
|
||||
pub fn new<P, F>(on_local_item: F, window: &mut Window, cx: &mut Context<P>) -> Self
|
||||
where
|
||||
P: EventEmitter<ContextMenuTriggered>,
|
||||
F: Fn(&mut P, usize, &mut Context<P>) + 'static,
|
||||
{
|
||||
let menu = cx.new(|cx| ContextMenu::new(0, window, cx));
|
||||
cx.subscribe(
|
||||
&menu,
|
||||
move |panel: &mut P, _menu, event: &ContextMenuEvent, cx| {
|
||||
if crate::actions::entry_for_menu_id(event.item).is_some() {
|
||||
cx.emit(ContextMenuTriggered { item: event.item });
|
||||
} else {
|
||||
on_local_item(panel, event.item, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
Self { menu }
|
||||
}
|
||||
|
||||
/// Open the menu at `position` (window coordinates).
|
||||
pub fn show(&self, position: Point<Pixels>, menu: Menu, cx: &mut App) {
|
||||
self.menu.update(cx, |menu_view, cx| menu_view.show(position, menu, cx));
|
||||
}
|
||||
|
||||
/// The popup entity, to be rendered as a child of the panel so the
|
||||
/// anchored popup can paint above it.
|
||||
pub fn widget(&self) -> Entity<ContextMenu> {
|
||||
self.menu.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared menu segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::actions::ActionId;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The app's reusable UI components — the presentation layer above raw
|
||||
//! gpui. Components own their theme wiring (colors come from the app
|
||||
//! theme, not hard-coded defaults), their key bindings and their
|
||||
//! interaction rules, so call sites stay declarative:
|
||||
//!
|
||||
//! ```
|
||||
//! use crate::oakui::component::{text_input, TextInput};
|
||||
//!
|
||||
//! // in a render():
|
||||
//! child(
|
||||
//! text_input("my-field", window, cx)
|
||||
//! .state(self.value.downgrade())
|
||||
//! .accepts_input(true),
|
||||
//! )
|
||||
//! ```
|
||||
//!
|
||||
//! Submodules:
|
||||
//! - [`text_input`]: the text field (IME, caret/selection, editing keys).
|
||||
//! - [`menu`]: menus (menu bar entries and context menus).
|
||||
|
||||
pub mod menu;
|
||||
pub mod text_input;
|
||||
|
||||
pub use menu::{ContextMenu, Menu, MenuItem, MenuBar};
|
||||
pub use text_input::{install_text_input_bindings, text_input, TextInput};
|
||||
@@ -0,0 +1,141 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The app's text input component.
|
||||
//!
|
||||
//! Wraps the gpui_elements editing engine (IME composition, caret,
|
||||
//! selection, undo history) with the app's own presentation rules:
|
||||
//!
|
||||
//! - **Theme colors**: text, caret, selection, placeholder and the IME
|
||||
//! marked underline all come from `App::default_colors` (the app theme)
|
||||
//! instead of the engine's hard-coded defaults. The field's text color
|
||||
//! is applied through a wrapping `div().text_color(…)` refinement so
|
||||
//! the engine's run layout (which reads `Window::text_style`) picks it
|
||||
//! up.
|
||||
//! - **Editing key bindings**: [`install_text_input_bindings`] installs
|
||||
//! the engine's Backspace/Delete/arrow/Home/End/select-all bindings
|
||||
//! into the app keymap, scoped to the `EditableText` key context. The
|
||||
//! app previously never installed them, so every field accepted IME
|
||||
//! text but every editing key was a no-op.
|
||||
//!
|
||||
//! IME text insertion itself flows through the engine's
|
||||
//! `EntityInputHandler` (registered by the element via
|
||||
//! `Window::handle_input`), so composing text, marked ranges and the
|
||||
//! candidate-window position all work once the field is focused.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
colors::{Colors, DefaultColors},
|
||||
div, App, Div, ElementId, Hsla, IntoElement, ParentElement, Styled, WeakEntity, Window,
|
||||
};
|
||||
use gpui_elements::editable_text::actions::{default_bindings, DEFAULT_INPUT_CONTEXT};
|
||||
use gpui_elements::editable_text::{EditableTextElement, EditableTextState};
|
||||
|
||||
/// Install the text-input editing key bindings into the app keymap.
|
||||
///
|
||||
/// The editing keys (Backspace, Delete, arrows, Home/End, select-all,
|
||||
/// …) are gpui actions bound to the `EditableText` key context; without
|
||||
/// them the fields accept IME text but every editing key is a no-op.
|
||||
/// Call once at app startup, alongside the registry key bindings.
|
||||
pub fn install_text_input_bindings(cx: &mut App) {
|
||||
cx.bind_keys(default_bindings().as_keybindings(Some(DEFAULT_INPUT_CONTEXT)));
|
||||
}
|
||||
|
||||
/// The app's text input component.
|
||||
///
|
||||
/// A `div` that carries the theme text color refinement around the
|
||||
/// gpui_elements editing element, with the theme's selection / caret /
|
||||
/// placeholder / IME-marked colors applied directly. Build with
|
||||
/// [`text_input`], then chain the engine's element options (`.state`,
|
||||
/// `.accepts_input`, …).
|
||||
pub struct TextInput {
|
||||
element: EditableTextElement,
|
||||
text_color: Hsla,
|
||||
selection: Hsla,
|
||||
caret: Hsla,
|
||||
placeholder: Hsla,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
/// Bind the field to an existing editing state entity (the caller
|
||||
/// reads the value / subscribes to changes through it).
|
||||
pub fn state(mut self, state: WeakEntity<EditableTextState>) -> Self {
|
||||
self.element = self.element.state(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the field accepts input at all (disabled fields render
|
||||
/// without the input handler attached).
|
||||
pub fn accepts_input(mut self, enabled: bool) -> Self {
|
||||
self.element = self.element.accepts_input(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Placeholder shown while the field is empty.
|
||||
pub fn placeholder(mut self, text: impl Into<gpui::SharedString>) -> Self {
|
||||
self.element = self.element.placeholder(text);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for TextInput {
|
||||
type Element = Div;
|
||||
|
||||
fn into_element(self) -> Div {
|
||||
let Self {
|
||||
element,
|
||||
text_color,
|
||||
selection,
|
||||
caret,
|
||||
placeholder,
|
||||
} = self;
|
||||
div()
|
||||
// The text_color refinement is pushed onto the window text
|
||||
// style during paint, so the engine's run layout (which reads
|
||||
// `Window::text_style().color`) renders in the theme text
|
||||
// color — the field reads like every other label in the app.
|
||||
.text_color(text_color)
|
||||
.child(
|
||||
element
|
||||
.placeholder_color(placeholder)
|
||||
.selection_color(selection)
|
||||
.caret_color(caret)
|
||||
.marked_color(text_color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the app's text input with the current theme's colors.
|
||||
///
|
||||
/// `cx` is used for the theme colors; the returned component needs no
|
||||
/// further styling to match the rest of the UI.
|
||||
pub fn text_input(id: impl Into<ElementId>, cx: &App) -> TextInput {
|
||||
let colors = theme_colors(cx);
|
||||
TextInput {
|
||||
element: gpui_elements::editable_text::text_input(id),
|
||||
text_color: colors.text.into(),
|
||||
selection: colors.selected.into(),
|
||||
caret: colors.text.into(),
|
||||
placeholder: colors.disabled.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme colors for the app (falls back to the defaults when no
|
||||
/// theme has been applied yet).
|
||||
fn theme_colors(cx: &App) -> Arc<Colors> {
|
||||
cx.default_colors().clone()
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
//! * [`timecode`] — timecode / duration / fps / resolution formatting (pure,
|
||||
//! unit tested).
|
||||
|
||||
pub mod component;
|
||||
pub mod displaycolor;
|
||||
pub mod effectchain;
|
||||
pub mod engine;
|
||||
|
||||
@@ -26,7 +26,8 @@ use gpui::{
|
||||
div, prelude::*, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, MouseButton,
|
||||
Render, SharedString, Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage, TextChanged};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage, TextChanged};
|
||||
use crate::oakui::component::text_input;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::AppEngine;
|
||||
@@ -162,7 +163,7 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
text_input("effect-library-search")
|
||||
text_input("effect-library-search", cx)
|
||||
.state(self.search.downgrade())
|
||||
.accepts_input(true),
|
||||
),
|
||||
|
||||
@@ -27,7 +27,7 @@ use gpui::{
|
||||
div, prelude::*, px, AnyElement, App, Context, ElementId, Entity, EventEmitter, MouseButton,
|
||||
MouseDownEvent, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::menu::{ContextMenu, ContextMenuEvent, Menu, MenuItem};
|
||||
use crate::oakui::component::menu::{ContextMenu, ContextMenuEvent, Menu, MenuItem};
|
||||
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
|
||||
@@ -26,9 +26,9 @@ use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, Render,
|
||||
SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::menu::{Menu, MenuItem};
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
|
||||
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
use crate::panels::ids::INSPECTOR;
|
||||
|
||||
@@ -37,10 +37,10 @@ use gpui::{
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
|
||||
EventEmitter, MouseButton, Pixels, Point, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::menu::{Menu, MenuItem};
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
|
||||
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::menus::shared;
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::component::menu;
|
||||
use crate::oakui::{AppEngine, NodeLibraryEntry};
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
use crate::panels::ids::NODE_EDITOR;
|
||||
@@ -156,7 +156,7 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
|
||||
/// Handles the node editor's local (non-registry) context-menu items.
|
||||
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
|
||||
if let Some(color) = shared::color_label_index(item) {
|
||||
if let Some(color) = menu::color_label_index(item) {
|
||||
println!("[node editor] set node color label to {color}");
|
||||
return;
|
||||
}
|
||||
@@ -442,13 +442,13 @@ const LOCAL_ADD_NODE_BASE: usize = 2420;
|
||||
/// viewer/parameter-editor reveals and properties (the C++ node branch).
|
||||
pub(crate) fn node_menu() -> Menu {
|
||||
use crate::i18n::tr;
|
||||
let mut items = shared::edit_section(false);
|
||||
let mut items = menu::edit_section(false);
|
||||
if let Some(last) = items.last_mut() {
|
||||
last.separator_after = true;
|
||||
}
|
||||
items.push(MenuItem::new(LOCAL_GROUP, tr("node.context.group")));
|
||||
items.push(MenuItem::new(LOCAL_UNGROUP, tr("node.context.ungroup")));
|
||||
items.push(shared::color_label_item(None).separated());
|
||||
items.push(menu::color_label_item(None).separated());
|
||||
items.push(MenuItem::new(LOCAL_OPEN_IN_VIEWER, tr("node.context.open_in_viewer")));
|
||||
items.push(MenuItem::new(
|
||||
LOCAL_SHOW_IN_PARAM_EDITOR,
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
//! the widget values from the engine snapshot on every render.
|
||||
|
||||
use std::sync::Arc;
|
||||
use crate::oakui::component::text_input;
|
||||
|
||||
use gpui::effect_stack::EffectId;
|
||||
use gpui::colors::DefaultColors;
|
||||
@@ -53,7 +54,7 @@ use gpui::{
|
||||
Anchor, App, Bounds, ElementId, Hsla, KeyDownEvent, MouseButton, MouseDownEvent, MouseUpEvent,
|
||||
Point, Pixels, Rgba, anchored, canvas, deferred, fill,
|
||||
};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
|
||||
use gpui_widgets::slider::{Slider, SliderEvent, SliderModel};
|
||||
@@ -912,7 +913,7 @@ impl<E: AppEngine> Render for OfxParamsView<E> {
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(text_input(format!("ofx-param-{}", control.input_id)).state(weak).accepts_input(true)),
|
||||
.child(text_input(format!("ofx-param-{}", control.input_id), cx).state(weak).accepts_input(true)),
|
||||
)
|
||||
.child(
|
||||
// Explicit commit: reads the field and pushes the
|
||||
@@ -1267,7 +1268,7 @@ impl OfxColorPicker {
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(
|
||||
text_input(format!("ofx-color-hex-{control}"))
|
||||
text_input(format!("ofx-color-hex-{control}"), cx)
|
||||
.state(hex_weak)
|
||||
.accepts_input(true),
|
||||
),
|
||||
|
||||
@@ -31,7 +31,8 @@ use gpui_widgets::scopes::{ChromaDataSource, Histogram, LumaDataSource, Vectorsc
|
||||
use gpui_widgets::viewer::{InteractPointerKind, PlaybackClock, ViewerEvent, ViewerWidget};
|
||||
|
||||
use crate::actions::ActionId;
|
||||
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::component::menu;
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::ofx::InteractViewport;
|
||||
use crate::oakui::timecode::{format_fps, format_resolution};
|
||||
use crate::oakui::{AppEngine, Monitor};
|
||||
@@ -206,7 +207,7 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
|
||||
/// Handles the viewer's local (non-registry) context-menu items.
|
||||
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
|
||||
use crate::menus::shared as shared_menu;
|
||||
use crate::oakui::component::menu as shared_menu;
|
||||
let divider = match item {
|
||||
shared_menu::LOCAL_VIEWER_RES_FULL => Some(1),
|
||||
shared_menu::LOCAL_VIEWER_RES_HALF => Some(2),
|
||||
@@ -550,7 +551,7 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
|
||||
let divider = this.engine.read(cx).playback_divider();
|
||||
this.context_menu.show(
|
||||
event.position,
|
||||
crate::menus::shared::viewer_menu(divider),
|
||||
menu::viewer_menu(divider),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
|
||||
@@ -25,12 +25,12 @@ use gpui::{
|
||||
div, px, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton,
|
||||
PathPromptOptions, Pixels, Point, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_widgets::menu::{Menu, MenuItem};
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent};
|
||||
|
||||
use crate::actions::ActionId;
|
||||
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::menus::shared;
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::component::menu;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
use crate::panels::ids::PROJECT;
|
||||
@@ -311,7 +311,7 @@ fn proxy_submenu(row: Option<&crate::oakui::engine::ProxyFootageRow>) -> Menu {
|
||||
use_proxy,
|
||||
reveal,
|
||||
delete,
|
||||
shared::action_item(ActionId::ProxySettings).separated(),
|
||||
menu::action_item(ActionId::ProxySettings).separated(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -319,8 +319,8 @@ fn proxy_submenu(row: Option<&crate::oakui::engine::ProxyFootageRow>) -> Menu {
|
||||
pub(crate) fn blank_menu() -> Menu {
|
||||
Menu::new(vec![
|
||||
MenuItem::new(0, crate::i18n::tr("project.context.new"))
|
||||
.with_submenu(Menu::new(shared::new_section())),
|
||||
shared::action_item(ActionId::Import),
|
||||
.with_submenu(Menu::new(menu::new_section())),
|
||||
menu::action_item(ActionId::Import),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ use gpui::{
|
||||
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
|
||||
|
||||
use crate::actions::ActionId;
|
||||
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::component::menu;
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::timecode::{format_fps, format_resolution};
|
||||
use crate::oakui::{AppEngine, Monitor};
|
||||
use crate::panels::commands::{self as panel_commands, PanelCommandHandler};
|
||||
@@ -86,7 +87,7 @@ impl<E: AppEngine> SourceViewerPanel<E> {
|
||||
|
||||
/// Handles the viewer's local (non-registry) context-menu items.
|
||||
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
|
||||
use crate::menus::shared as shared_menu;
|
||||
use crate::oakui::component::menu as shared_menu;
|
||||
let divider = match item {
|
||||
shared_menu::LOCAL_VIEWER_RES_FULL => Some(1),
|
||||
shared_menu::LOCAL_VIEWER_RES_HALF => Some(2),
|
||||
@@ -161,7 +162,7 @@ impl<E: AppEngine> Render for SourceViewerPanel<E> {
|
||||
let divider = this.engine.read(cx).playback_divider();
|
||||
this.context_menu.show(
|
||||
event.position,
|
||||
crate::menus::shared::viewer_menu(divider),
|
||||
menu::viewer_menu(divider),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
|
||||
+13
-13
@@ -50,7 +50,7 @@ use gpui::{
|
||||
};
|
||||
use gpui::{AnyElement, App, ClickEvent, DragMoveEvent, EventEmitter, Render, SharedString};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::menu::{Menu, MenuItem};
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
use gpui_widgets::project_explorer::FootageDrag;
|
||||
use gpui_widgets::slider::{Slider, SliderEvent, SliderModel};
|
||||
@@ -59,8 +59,8 @@ use gpui_widgets::value::ValueKind;
|
||||
|
||||
use crate::actions::ActionId;
|
||||
use crate::i18n;
|
||||
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::menus::shared;
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::component::menu;
|
||||
use crate::oakui::icons;
|
||||
use crate::oakui::{AppEngine, Monitor};
|
||||
use crate::panels::commands::{self as panel_commands, PanelCommandHandler};
|
||||
@@ -269,7 +269,7 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
// Color labels apply to the selected clips; the engine has no
|
||||
// clip-color surface yet, so they log for now (kept visible so the
|
||||
// wiring is testable in the demo).
|
||||
if let Some(color) = shared::color_label_index(item) {
|
||||
if let Some(color) = menu::color_label_index(item) {
|
||||
println!("[timeline] set clip color label to {color}");
|
||||
return;
|
||||
}
|
||||
@@ -1078,29 +1078,29 @@ pub(crate) fn clip_menu(
|
||||
proxy: &[crate::oakui::engine::ProxyFootageRow],
|
||||
multicam: Option<MulticamMenuState>,
|
||||
) -> Menu {
|
||||
let mut items = shared::edit_section(true);
|
||||
let mut items = menu::edit_section(true);
|
||||
// The C++ puts a separator between the edit section and the color
|
||||
// labels, and another after them.
|
||||
if let Some(last) = items.last_mut() {
|
||||
last.separator_after = true;
|
||||
}
|
||||
items.push(shared::color_label_item(None).separated());
|
||||
items.push(menu::color_label_item(None).separated());
|
||||
// Synchronize group (registry actions; enabled at ≥ 2 eligible clips,
|
||||
// the C++ `get_selected_source_sync_clips` / `_waveform_sync_clips`
|
||||
// counts).
|
||||
let sync_enabled = sync.source_time >= 2;
|
||||
let wave_enabled = sync.waveform >= 2;
|
||||
let mut source_time = shared::action_item(ActionId::SyncBySourceTime);
|
||||
let mut source_time = menu::action_item(ActionId::SyncBySourceTime);
|
||||
if !sync_enabled {
|
||||
source_time = source_time.disabled();
|
||||
}
|
||||
items.push(source_time);
|
||||
let mut waveform = shared::action_item(ActionId::SyncByWaveform);
|
||||
let mut waveform = menu::action_item(ActionId::SyncByWaveform);
|
||||
if !wave_enabled {
|
||||
waveform = waveform.disabled();
|
||||
}
|
||||
items.push(waveform);
|
||||
let mut waveform_speed = shared::action_item(ActionId::SyncByWaveformSpeed).separated();
|
||||
let mut waveform_speed = menu::action_item(ActionId::SyncByWaveformSpeed).separated();
|
||||
if !wave_enabled {
|
||||
waveform_speed = waveform_speed.disabled();
|
||||
}
|
||||
@@ -1144,7 +1144,7 @@ pub(crate) fn clip_menu(
|
||||
use_proxy,
|
||||
reveal,
|
||||
delete,
|
||||
shared::action_item(ActionId::ProxySettings).separated(),
|
||||
menu::action_item(ActionId::ProxySettings).separated(),
|
||||
]);
|
||||
items.push(MenuItem::new(0, i18n::tr("timeline.context.proxy")).with_submenu(proxy_menu));
|
||||
// Reveal / multi-cam entries (the C++ shows them only when the clip is
|
||||
@@ -1218,8 +1218,8 @@ pub(crate) fn track_head_menu() -> Menu {
|
||||
/// The marker context menu (`SeekableWidget`): color labels, the plain
|
||||
/// edit section and marker properties.
|
||||
pub(crate) fn marker_menu() -> Menu {
|
||||
let mut items = vec![shared::color_label_item(None).separated()];
|
||||
let mut edit_items = shared::edit_section(false);
|
||||
let mut items = vec![menu::color_label_item(None).separated()];
|
||||
let mut edit_items = menu::edit_section(false);
|
||||
// Separator before the trailing "Properties" entry (the C++ layout).
|
||||
if let Some(last) = edit_items.last_mut() {
|
||||
last.separator_after = true;
|
||||
@@ -1418,7 +1418,7 @@ mod tests {
|
||||
.expect("color label item");
|
||||
assert_eq!(
|
||||
color.submenu.as_ref().unwrap().items.len(),
|
||||
shared::COLOR_LABEL_COUNT
|
||||
menu::COLOR_LABEL_COUNT
|
||||
);
|
||||
|
||||
// The synchronize entries are the registry actions and stay
|
||||
|
||||
Reference in New Issue
Block a user