feat: add log and hotkey list
This commit is contained in:
parent
7852790a1e
commit
41ea9d13cb
5 changed files with 415 additions and 4 deletions
|
|
@ -9,10 +9,12 @@ pub mod conversation_modal;
|
|||
pub mod editor;
|
||||
pub mod emotion_picker;
|
||||
pub mod forms;
|
||||
pub mod hotkey_help;
|
||||
pub mod inventory;
|
||||
pub mod keybindings;
|
||||
pub mod keybindings_popup;
|
||||
pub mod layout;
|
||||
pub mod log_popup;
|
||||
pub mod modals;
|
||||
pub mod notification_history;
|
||||
pub mod notifications;
|
||||
|
|
@ -33,10 +35,12 @@ pub use conversation_modal::*;
|
|||
pub use editor::*;
|
||||
pub use emotion_picker::*;
|
||||
pub use forms::*;
|
||||
pub use hotkey_help::*;
|
||||
pub use inventory::*;
|
||||
pub use keybindings::*;
|
||||
pub use keybindings_popup::*;
|
||||
pub use layout::*;
|
||||
pub use log_popup::*;
|
||||
pub use modals::*;
|
||||
pub use notification_history::*;
|
||||
pub use notifications::*;
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> {
|
|||
/// - `on_focus_change`: Callback when focus state changes
|
||||
/// - `on_open_settings`: Callback to open settings popup
|
||||
/// - `on_open_inventory`: Callback to open inventory popup
|
||||
/// - `on_open_log`: Callback to open message log popup
|
||||
/// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill)
|
||||
/// - `scenes`: List of available scenes for teleport command
|
||||
/// - `allow_user_teleport`: Whether teleporting is enabled for this realm
|
||||
|
|
@ -123,6 +124,9 @@ pub fn ChatInput(
|
|||
on_focus_change: Callback<bool>,
|
||||
#[prop(optional)] on_open_settings: Option<Callback<()>>,
|
||||
#[prop(optional)] on_open_inventory: Option<Callback<()>>,
|
||||
/// Callback to open message log popup.
|
||||
#[prop(optional)]
|
||||
on_open_log: Option<Callback<()>>,
|
||||
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
||||
#[prop(optional, into)]
|
||||
whisper_target: Option<Signal<Option<String>>>,
|
||||
|
|
@ -324,9 +328,11 @@ pub fn ChatInput(
|
|||
|| "inventory".starts_with(&cmd)
|
||||
|| "whisper".starts_with(&cmd)
|
||||
|| "teleport".starts_with(&cmd)
|
||||
|| "log".starts_with(&cmd)
|
||||
|| cmd == "setting"
|
||||
|| cmd == "settings"
|
||||
|| cmd == "inventory"
|
||||
|| cmd == "log"
|
||||
|| cmd.starts_with("w ")
|
||||
|| cmd.starts_with("whisper ")
|
||||
|| cmd.starts_with("t ")
|
||||
|
|
@ -348,6 +354,7 @@ pub fn ChatInput(
|
|||
let apply_emotion = apply_emotion.clone();
|
||||
let on_open_settings = on_open_settings.clone();
|
||||
let on_open_inventory = on_open_inventory.clone();
|
||||
let on_open_log = on_open_log.clone();
|
||||
move |ev: web_sys::KeyboardEvent| {
|
||||
let key = ev.key();
|
||||
let current_mode = command_mode.get_untracked();
|
||||
|
|
@ -484,6 +491,20 @@ pub fn ChatInput(
|
|||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
// Autocomplete to /log if /l, /lo (but not if it could be /list or /teleport)
|
||||
// Only match /l if it's exactly /l (not /li which would match /list)
|
||||
if !cmd.is_empty()
|
||||
&& "log".starts_with(&cmd)
|
||||
&& cmd != "log"
|
||||
&& !cmd.starts_with("li")
|
||||
{
|
||||
set_message.set("/log".to_string());
|
||||
if let Some(input) = input_ref.get() {
|
||||
input.set_value("/log");
|
||||
}
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Always prevent Tab from moving focus when in input
|
||||
ev.prevent_default();
|
||||
|
|
@ -527,6 +548,21 @@ pub fn ChatInput(
|
|||
return;
|
||||
}
|
||||
|
||||
// /l, /lo, /log - open message log
|
||||
if !cmd.is_empty() && "log".starts_with(&cmd) {
|
||||
if let Some(ref callback) = on_open_log {
|
||||
callback.run(());
|
||||
}
|
||||
set_message.set(String::new());
|
||||
set_command_mode.set(CommandMode::None);
|
||||
if let Some(input) = input_ref.get() {
|
||||
input.set_value("");
|
||||
let _ = input.blur();
|
||||
}
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
|
||||
// /w NAME message or /whisper NAME message
|
||||
if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) {
|
||||
if !whisper_content.trim().is_empty() {
|
||||
|
|
@ -702,7 +738,7 @@ pub fn ChatInput(
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport])
|
||||
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /l[og], /t[eleport])
|
||||
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
|
||||
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
||||
<span class="text-gray-400">"/"</span>
|
||||
|
|
@ -716,6 +752,10 @@ pub fn ChatInput(
|
|||
<span class="text-gray-400">"/"</span>
|
||||
<span class="text-blue-400">"w"</span>
|
||||
<span class="text-gray-500">"[hisper] name"</span>
|
||||
<span class="text-gray-600 mx-2">"|"</span>
|
||||
<span class="text-gray-400">"/"</span>
|
||||
<span class="text-blue-400">"l"</span>
|
||||
<span class="text-gray-500">"[og]"</span>
|
||||
<Show when=move || allow_user_teleport.get()>
|
||||
<span class="text-gray-600 mx-2">"|"</span>
|
||||
<span class="text-gray-400">"/"</span>
|
||||
|
|
|
|||
106
crates/chattyness-user-ui/src/components/hotkey_help.rs
Normal file
106
crates/chattyness-user-ui/src/components/hotkey_help.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Hotkey help overlay component.
|
||||
//!
|
||||
//! Displays available keyboard shortcuts while held.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Hotkey help overlay that shows available keyboard shortcuts.
|
||||
///
|
||||
/// This component displays while the user holds down the `?` key.
|
||||
#[component]
|
||||
pub fn HotkeyHelp(
|
||||
/// Whether the help overlay is visible.
|
||||
#[prop(into)]
|
||||
visible: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let outer_class = move || {
|
||||
if visible.get() {
|
||||
"fixed inset-0 z-50 flex items-center justify-center pointer-events-none"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class=outer_class>
|
||||
// Semi-transparent backdrop
|
||||
<div class="absolute inset-0 bg-black/60" aria-hidden="true" />
|
||||
|
||||
// Help content
|
||||
<div class="relative bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl p-6 border border-gray-600 max-w-md">
|
||||
<h2 class="text-lg font-bold text-white mb-4 text-center">
|
||||
"Keyboard Shortcuts"
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
// Navigation & Chat
|
||||
<div class="col-span-2 text-gray-400 font-medium mt-2 first:mt-0">
|
||||
"Navigation & Chat"
|
||||
</div>
|
||||
<HotkeyRow key="Space" description="Focus chat input" />
|
||||
<HotkeyRow key=":" description="Open emote commands" />
|
||||
<HotkeyRow key="/" description="Open slash commands" />
|
||||
<HotkeyRow key="Esc" description="Close/unfocus" />
|
||||
|
||||
// Popups
|
||||
<div class="col-span-2 text-gray-400 font-medium mt-3">
|
||||
"Popups"
|
||||
</div>
|
||||
<HotkeyRow key="s" description="Settings" />
|
||||
<HotkeyRow key="i" description="Inventory" />
|
||||
<HotkeyRow key="k" description="Keybindings" />
|
||||
<HotkeyRow key="a" description="Avatar editor" />
|
||||
<HotkeyRow key="l" description="Message log" />
|
||||
|
||||
// Emotions
|
||||
<div class="col-span-2 text-gray-400 font-medium mt-3">
|
||||
"Emotions"
|
||||
</div>
|
||||
<div class="col-span-2 text-gray-300">
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"e"</kbd>
|
||||
" + "
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"0-9"</kbd>
|
||||
" / "
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"q"</kbd>
|
||||
" / "
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"w"</kbd>
|
||||
<span class="text-gray-500 ml-2">"Apply emotion"</span>
|
||||
</div>
|
||||
|
||||
// View controls
|
||||
<div class="col-span-2 text-gray-400 font-medium mt-3">
|
||||
"View (when panning enabled)"
|
||||
</div>
|
||||
<HotkeyRow key="Arrows" description="Pan view" />
|
||||
<HotkeyRow key="+/-" description="Zoom in/out" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-t border-gray-600 text-xs text-gray-500 text-center">
|
||||
"Release " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"?"</kbd> " to close"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A single hotkey row with key and description.
|
||||
#[component]
|
||||
fn HotkeyRow(
|
||||
/// The key or key combination.
|
||||
key: &'static str,
|
||||
/// Description of what the key does.
|
||||
description: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="contents">
|
||||
<div class="text-right">
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs text-gray-200">
|
||||
{key}
|
||||
</kbd>
|
||||
</div>
|
||||
<div class="text-gray-300">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
206
crates/chattyness-user-ui/src/components/log_popup.rs
Normal file
206
crates/chattyness-user-ui/src/components/log_popup.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
//! Message log popup component.
|
||||
//!
|
||||
//! Displays a filterable chronological log of received messages.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::reactive::owner::LocalStorage;
|
||||
|
||||
use super::chat_types::{ChatMessage, MessageLog};
|
||||
use super::modals::Modal;
|
||||
|
||||
/// Filter mode for message log display.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LogFilter {
|
||||
/// Show all messages.
|
||||
#[default]
|
||||
All,
|
||||
/// Show only broadcast chat messages.
|
||||
Chat,
|
||||
/// Show only whispers.
|
||||
Whispers,
|
||||
}
|
||||
|
||||
/// Message log popup component.
|
||||
///
|
||||
/// Displays a filterable list of messages from the session.
|
||||
#[component]
|
||||
pub fn LogPopup(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
message_log: StoredValue<MessageLog, LocalStorage>,
|
||||
on_close: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let (filter, set_filter) = signal(LogFilter::All);
|
||||
|
||||
// Get filtered messages based on current filter
|
||||
// Note: We read `open` to force re-evaluation when the modal opens,
|
||||
// since StoredValue is not reactive.
|
||||
let filtered_messages = move || {
|
||||
// Reading open ensures we re-fetch messages when modal opens
|
||||
let _ = open.get();
|
||||
let current_filter = filter.get();
|
||||
message_log.with_value(|log| {
|
||||
log.all_messages()
|
||||
.iter()
|
||||
.filter(|msg| match current_filter {
|
||||
LogFilter::All => true,
|
||||
LogFilter::Chat => !msg.is_whisper,
|
||||
LogFilter::Whispers => msg.is_whisper,
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when modal opens
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
Effect::new(move |_| {
|
||||
if open.get() {
|
||||
// Use a small delay to ensure the DOM is rendered
|
||||
use gloo_timers::callback::Timeout;
|
||||
Timeout::new(50, || {
|
||||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(container) = document
|
||||
.query_selector(".log-popup-messages")
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
let scroll_height = container.scroll_height();
|
||||
container.set_scroll_top(scroll_height);
|
||||
}
|
||||
}
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let tab_class = |is_active: bool| {
|
||||
if is_active {
|
||||
"px-3 py-1.5 rounded text-sm font-medium bg-blue-600 text-white"
|
||||
} else {
|
||||
"px-3 py-1.5 rounded text-sm font-medium bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Modal
|
||||
open=open
|
||||
on_close=on_close.clone()
|
||||
title="Message Log"
|
||||
title_id="message-log-title"
|
||||
max_width="max-w-2xl"
|
||||
>
|
||||
// Filter tabs
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
class=move || tab_class(filter.get() == LogFilter::All)
|
||||
on:click=move |_| set_filter.set(LogFilter::All)
|
||||
>
|
||||
"All"
|
||||
</button>
|
||||
<button
|
||||
class=move || tab_class(filter.get() == LogFilter::Chat)
|
||||
on:click=move |_| set_filter.set(LogFilter::Chat)
|
||||
>
|
||||
"Chat"
|
||||
</button>
|
||||
<button
|
||||
class=move || tab_class(filter.get() == LogFilter::Whispers)
|
||||
on:click=move |_| set_filter.set(LogFilter::Whispers)
|
||||
>
|
||||
"Whispers"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Message list
|
||||
<div class="log-popup-messages max-h-[70vh] overflow-y-auto bg-gray-900/50 rounded-lg p-2">
|
||||
<Show
|
||||
when=move || !filtered_messages().is_empty()
|
||||
fallback=|| view! {
|
||||
<p class="text-gray-400 text-center py-8">
|
||||
"No messages yet"
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ul class="space-y-1 font-mono text-sm">
|
||||
<For
|
||||
each=move || filtered_messages()
|
||||
key=|msg| msg.message_id
|
||||
children=move |msg: ChatMessage| {
|
||||
let is_whisper = msg.is_whisper;
|
||||
let display_name = msg.display_name.clone();
|
||||
let content = msg.content.clone();
|
||||
let timestamp = msg.timestamp;
|
||||
|
||||
view! {
|
||||
<li class=move || {
|
||||
if is_whisper {
|
||||
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
|
||||
} else {
|
||||
"py-1 px-2"
|
||||
}
|
||||
}>
|
||||
<span class="text-gray-500">
|
||||
"["
|
||||
{format_timestamp(timestamp)}
|
||||
"] "
|
||||
</span>
|
||||
<span class=move || {
|
||||
if is_whisper {
|
||||
"text-purple-300 font-medium"
|
||||
} else {
|
||||
"text-blue-300 font-medium"
|
||||
}
|
||||
}>
|
||||
{display_name}
|
||||
</span>
|
||||
<Show when=move || is_whisper>
|
||||
<span class="text-purple-400 text-xs ml-1">
|
||||
"(whisper)"
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-gray-400">
|
||||
": "
|
||||
</span>
|
||||
<span class=move || {
|
||||
if is_whisper {
|
||||
"text-gray-300 italic"
|
||||
} else {
|
||||
"text-gray-200"
|
||||
}
|
||||
}>
|
||||
{content}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
// Footer hint
|
||||
<div class="mt-4 pt-4 border-t border-gray-600 text-xs text-gray-500 text-center">
|
||||
"Press " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a timestamp for display (HH:MM:SS).
|
||||
fn format_timestamp(timestamp: i64) -> String {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(timestamp as f64));
|
||||
let hours = date.get_hours();
|
||||
let minutes = date.get_minutes();
|
||||
let seconds = date.get_seconds();
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
let _ = timestamp;
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,9 +13,9 @@ use uuid::Uuid;
|
|||
|
||||
use crate::components::{
|
||||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||||
FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal,
|
||||
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
|
||||
SettingsPopup, ViewerSettings,
|
||||
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
||||
NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
|
||||
RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings,
|
||||
};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::components::{
|
||||
|
|
@ -74,6 +74,12 @@ pub fn RealmPage() -> impl IntoView {
|
|||
let (settings_open, set_settings_open) = signal(false);
|
||||
let viewer_settings = RwSignal::new(ViewerSettings::load());
|
||||
|
||||
// Log popup state
|
||||
let (log_open, set_log_open) = signal(false);
|
||||
|
||||
// Hotkey help overlay state (shown while ? is held)
|
||||
let (hotkey_help_visible, set_hotkey_help_visible) = signal(false);
|
||||
|
||||
// Keybindings popup state
|
||||
let keybindings = RwSignal::new(EmotionKeybindings::load());
|
||||
let (keybindings_open, set_keybindings_open) = signal(false);
|
||||
|
|
@ -794,6 +800,20 @@ pub fn RealmPage() -> impl IntoView {
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle 'l' to toggle message log
|
||||
if key == "l" || key == "L" {
|
||||
set_log_open.update(|v| *v = !*v);
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle '?' to show hotkey help (while held)
|
||||
if key == "?" {
|
||||
set_hotkey_help_visible.set(true);
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if 'e' key was pressed
|
||||
if key == "e" || key == "E" {
|
||||
*e_pressed_clone.borrow_mut() = true;
|
||||
|
|
@ -830,6 +850,25 @@ pub fn RealmPage() -> impl IntoView {
|
|||
|
||||
// Store the closure for cleanup
|
||||
*closure_holder_clone.borrow_mut() = Some(closure);
|
||||
|
||||
// Add keyup handler for releasing '?' (hotkey help)
|
||||
let keyup_closure = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
||||
move |ev: web_sys::KeyboardEvent| {
|
||||
if ev.key() == "?" {
|
||||
set_hotkey_help_visible.set(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.add_event_listener_with_callback(
|
||||
"keyup",
|
||||
keyup_closure.as_ref().unchecked_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
// Forget the keyup closure (it lives for the duration of the page)
|
||||
keyup_closure.forget();
|
||||
});
|
||||
|
||||
// Save position on page unload (beforeunload event)
|
||||
|
|
@ -982,6 +1021,9 @@ pub fn RealmPage() -> impl IntoView {
|
|||
let on_open_inventory_cb = Callback::new(move |_: ()| {
|
||||
set_inventory_open.set(true);
|
||||
});
|
||||
let on_open_log_cb = Callback::new(move |_: ()| {
|
||||
set_log_open.set(true);
|
||||
});
|
||||
let whisper_target_signal = Signal::derive(move || whisper_target.get());
|
||||
let on_whisper_request_cb = Callback::new(move |target: String| {
|
||||
set_whisper_target.set(Some(target));
|
||||
|
|
@ -1031,6 +1073,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
on_focus_change=on_chat_focus_change.clone()
|
||||
on_open_settings=on_open_settings_cb
|
||||
on_open_inventory=on_open_inventory_cb
|
||||
on_open_log=on_open_log_cb
|
||||
whisper_target=whisper_target_signal
|
||||
scenes=scenes_signal
|
||||
allow_user_teleport=teleport_enabled_signal
|
||||
|
|
@ -1088,6 +1131,15 @@ pub fn RealmPage() -> impl IntoView {
|
|||
scene_dimensions=scene_dimensions.get()
|
||||
/>
|
||||
|
||||
// Log popup
|
||||
<LogPopup
|
||||
open=Signal::derive(move || log_open.get())
|
||||
message_log=message_log
|
||||
on_close=Callback::new(move |_: ()| {
|
||||
set_log_open.set(false);
|
||||
})
|
||||
/>
|
||||
|
||||
// Keybindings popup
|
||||
<KeybindingsPopup
|
||||
open=Signal::derive(move || keybindings_open.get())
|
||||
|
|
@ -1226,6 +1278,9 @@ pub fn RealmPage() -> impl IntoView {
|
|||
/>
|
||||
}
|
||||
}
|
||||
|
||||
// Hotkey help overlay (shown while ? is held)
|
||||
<HotkeyHelp visible=Signal::derive(move || hotkey_help_visible.get()) />
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue