Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e1740d3c |
@@ -1,27 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to PeerSpeak are documented here.
|
|
||||||
|
|
||||||
## [0.5.0] — 2026-06-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Right-click context menu** (Cut / Copy / Paste / Select All) on every text-entry field. (A9)
|
|
||||||
- **Selectable, copyable text** for values that used to be read-only: your full node ID and the room ticket can now be click-selected and copied, and **chat messages are drag-selectable** (highlight + Ctrl+C / Ctrl+A) while clickable links keep working. (W21 Phase 1 + 2)
|
|
||||||
- **Clock-skew warning**: when a peer can't be seen because the two systems' clocks differ by more than the replay-protection window, PeerSpeak now shows a "your clocks are out of sync" banner instead of failing silently. (A25)
|
|
||||||
- **Per-application audio capture for screen-share**, removing the call-audio loopback echo when sharing a window, plus surfacing of pixelpass startup errors. (A23)
|
|
||||||
|
|
||||||
### Changed / Fixed
|
|
||||||
- **Critical commands are now delivered reliably under load** — muting, releasing push-to-talk, and leaving a room can no longer be silently dropped while a slider is being dragged (prevents a hot-mic state mismatch). (A15)
|
|
||||||
- Screen-share now passes `--strict-audio` and surfaces app-audio drop warnings; the source picker state machine and pactl handling were hardened. (A23)
|
|
||||||
- Per-peer volume control path verified and covered by regression tests. (A24)
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- Hardened against insider resource-exhaustion: bounded + author-keyed chat attachment cache, capped recovery-identity state, and other Tier-C caps (F-01 / F-02 / F-03 / F-12).
|
|
||||||
|
|
||||||
### Packaging
|
|
||||||
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
|
|
||||||
- Arch `PKGBUILD` clones over anonymous HTTPS.
|
|
||||||
|
|
||||||
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
|
|
||||||
|
|
||||||
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
|
|
||||||
Generated
+1
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.5.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.5.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||||
@@ -57,7 +57,7 @@ async-trait = "0.1.89"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bytes = "1.11.1"
|
bytes = "1.11.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] }
|
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] }
|
||||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||||
|
|||||||
@@ -225,6 +225,20 @@ state change; rate-limit pings), tickets from friends (validate defensively, no
|
|||||||
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
||||||
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
||||||
|
|
||||||
|
**Local hardening DONE 2026-06-27:** inbound friend-presence replies are now
|
||||||
|
rate-limited per authenticated friend id (`PresenceRateLimiter`: burst 4, refill
|
||||||
|
1/15s) and wired into the live friends listener before it builds a `Pong`; denied
|
||||||
|
probes get the same silent no-data close as unauthorized probes. Existing
|
||||||
|
defensive reply handling still validates room tickets against the authenticated
|
||||||
|
friend id and never auto-joins. Verified with `cargo test presence`,
|
||||||
|
`cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, and
|
||||||
|
`cargo audit --no-fetch --stale` (local DB; reports only the two already-allowed
|
||||||
|
unmaintained advisories in `deny.toml`). A fresh advisory fetch was blocked in
|
||||||
|
this sandbox by network restrictions.
|
||||||
|
|
||||||
|
**Remaining:** live 2-machine field test on dopedart, a fresh online
|
||||||
|
`cargo audit`, and any follow-up findings from that test.
|
||||||
|
|
||||||
## The connect flow (the user's scenario, end to end)
|
## The connect flow (the user's scenario, end to end)
|
||||||
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
||||||
"HangOut."
|
"HangOut."
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.5.0.r0.g0000000
|
pkgver=0.4.0.r254.g913b0b6
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -14,7 +14,7 @@ optdepends=('pixelpass: screen sharing inside a room'
|
|||||||
provides=('peerspeak')
|
provides=('peerspeak')
|
||||||
conflicts=('peerspeak')
|
conflicts=('peerspeak')
|
||||||
options=('!lto' '!debug')
|
options=('!lto' '!debug')
|
||||||
source=("$_pkgname::git+https://gitbutter.xyz/mollusk/peerspeak.git")
|
source=("$_pkgname::git+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
pkgver() {
|
pkgver() {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
#define MyAppName "PeerSpeak"
|
#define MyAppName "PeerSpeak"
|
||||||
#define MyAppVersion "0.5.0"
|
#define MyAppVersion "0.4.0"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
+23
-210
@@ -11,15 +11,12 @@ use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
|||||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||||
use crate::presence::PresenceMode;
|
use crate::presence::PresenceMode;
|
||||||
use crate::theme::{AppTheme, Palette};
|
use crate::theme::{AppTheme, Palette};
|
||||||
use crate::widget::context_input::{context_input, locked_value};
|
|
||||||
use crate::widget::selectable_text::selectable_rich_text;
|
|
||||||
|
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
container, column, row, text, button, scrollable, slider, checkbox, pick_list,
|
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
||||||
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
||||||
span, responsive,
|
rich_text, span, responsive,
|
||||||
};
|
};
|
||||||
use iced::widget::text_input;
|
|
||||||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||||||
use iced::{
|
use iced::{
|
||||||
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
|
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
|
||||||
@@ -265,8 +262,6 @@ const ABOVE_CHAT_MIN_H: f32 = 300.0;
|
|||||||
const DIVIDER_THICKNESS: f32 = 8.0;
|
const DIVIDER_THICKNESS: f32 = 8.0;
|
||||||
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
|
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
|
||||||
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
|
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
|
||||||
/// How long a room-level clock-skew warning remains visible without dismissal.
|
|
||||||
const CLOCK_SKEW_WARNING_VISIBLE_SECS: u64 = 12;
|
|
||||||
|
|
||||||
/// Clamp the Participants panel width so neither it nor the Controls panel drops
|
/// Clamp the Participants panel width so neither it nor the Controls panel drops
|
||||||
/// below its minimum, given the current window width.
|
/// below its minimum, given the current window width.
|
||||||
@@ -314,8 +309,6 @@ pub enum AppMessage {
|
|||||||
CopyToClipboard,
|
CopyToClipboard,
|
||||||
/// Copy an arbitrary string to the clipboard (e.g. the full node ID).
|
/// Copy an arbitrary string to the clipboard (e.g. the full node ID).
|
||||||
CopyText(String),
|
CopyText(String),
|
||||||
/// No-op message for controlled read-only selectable fields.
|
|
||||||
Noop,
|
|
||||||
TogglePtt(bool),
|
TogglePtt(bool),
|
||||||
StartHotkeyCapture(HotkeyAction),
|
StartHotkeyCapture(HotkeyAction),
|
||||||
ClearHotkey(HotkeyAction),
|
ClearHotkey(HotkeyAction),
|
||||||
@@ -411,10 +404,6 @@ pub enum AppMessage {
|
|||||||
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
|
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
|
||||||
OpenPixelpassHelp,
|
OpenPixelpassHelp,
|
||||||
ClosePixelpassHelp,
|
ClosePixelpassHelp,
|
||||||
/// Dismiss the room-level clock-skew warning banner.
|
|
||||||
DismissClockSkewWarning,
|
|
||||||
/// Auto-clear cadence while the clock-skew warning banner is visible.
|
|
||||||
ClockSkewWarningTick,
|
|
||||||
/// Choose a room layout (applied live + persisted, closes the popup).
|
/// Choose a room layout (applied live + persisted, closes the popup).
|
||||||
SelectRoomLayout(RoomLayout),
|
SelectRoomLayout(RoomLayout),
|
||||||
/// Choose a UI theme (applied live + persisted).
|
/// Choose a UI theme (applied live + persisted).
|
||||||
@@ -510,13 +499,6 @@ fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct ClockSkewBanner {
|
|
||||||
skew_secs: u64,
|
|
||||||
peer_ahead: bool,
|
|
||||||
expires_at: std::time::Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
name: String,
|
name: String,
|
||||||
ticket_input: String,
|
ticket_input: String,
|
||||||
@@ -622,10 +604,6 @@ pub struct AppState {
|
|||||||
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
|
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
|
||||||
/// until the core's `AudioAppsListed` reports otherwise.
|
/// until the core's `AudioAppsListed` reports otherwise.
|
||||||
share_app_audio_supported: bool,
|
share_app_audio_supported: bool,
|
||||||
/// Room-level warning for a validly signed peer whose gossip timestamp falls
|
|
||||||
/// outside the replay freshness window. The peer is not yet in the roster, so
|
|
||||||
/// this is not attached to a participant card.
|
|
||||||
clock_skew_warning: Option<ClockSkewBanner>,
|
|
||||||
/// Whether the Chat drawer is open (drawer layout only).
|
/// Whether the Chat drawer is open (drawer layout only).
|
||||||
drawer_chat_open: bool,
|
drawer_chat_open: bool,
|
||||||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||||
@@ -702,7 +680,6 @@ impl AppState {
|
|||||||
self.share_audio_dropped = false;
|
self.share_audio_dropped = false;
|
||||||
self.share_audio_app_active = false;
|
self.share_audio_app_active = false;
|
||||||
self.share_app_audio_supported = true;
|
self.share_app_audio_supported = true;
|
||||||
self.clock_skew_warning = None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||||||
@@ -831,7 +808,6 @@ impl Default for AppState {
|
|||||||
share_audio_dropped: false,
|
share_audio_dropped: false,
|
||||||
share_audio_app_active: false,
|
share_audio_app_active: false,
|
||||||
share_app_audio_supported: true,
|
share_app_audio_supported: true,
|
||||||
clock_skew_warning: None,
|
|
||||||
drawer_chat_open: false,
|
drawer_chat_open: false,
|
||||||
mic_level: 0.0,
|
mic_level: 0.0,
|
||||||
mic_test_active: false,
|
mic_test_active: false,
|
||||||
@@ -976,13 +952,7 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
|||||||
} else {
|
} else {
|
||||||
Subscription::none()
|
Subscription::none()
|
||||||
};
|
};
|
||||||
let clock_skew_sub = if state.clock_skew_warning.is_some() {
|
Subscription::batch(vec![core_sub, event_sub, audio_sub])
|
||||||
iced::time::every(std::time::Duration::from_secs(1))
|
|
||||||
.map(|_| AppMessage::ClockSkewWarningTick)
|
|
||||||
} else {
|
|
||||||
Subscription::none()
|
|
||||||
};
|
|
||||||
Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shutdown_timeout_task() -> Task<AppMessage> {
|
fn shutdown_timeout_task() -> Task<AppMessage> {
|
||||||
@@ -1459,14 +1429,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.share_audio_dropped = !active;
|
state.share_audio_dropped = !active;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiEvent::ClockSkewWarning { skew_secs, peer_ahead } => {
|
|
||||||
show_clock_skew_warning(
|
|
||||||
state,
|
|
||||||
skew_secs,
|
|
||||||
peer_ahead,
|
|
||||||
std::time::Instant::now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
UiEvent::IdentityStatus { node_id, persisted, error } => {
|
UiEvent::IdentityStatus { node_id, persisted, error } => {
|
||||||
state.self_node_id = Some(node_id);
|
state.self_node_id = Some(node_id);
|
||||||
state.identity_persisted = persisted;
|
state.identity_persisted = persisted;
|
||||||
@@ -1525,7 +1487,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::CopyText(s) => {
|
AppMessage::CopyText(s) => {
|
||||||
return iced::clipboard::write(s);
|
return iced::clipboard::write(s);
|
||||||
}
|
}
|
||||||
AppMessage::Noop => {}
|
|
||||||
AppMessage::TogglePtt(enabled) => {
|
AppMessage::TogglePtt(enabled) => {
|
||||||
state.ptt_enabled = enabled;
|
state.ptt_enabled = enabled;
|
||||||
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
|
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
|
||||||
@@ -1832,12 +1793,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::ClosePixelpassHelp => {
|
AppMessage::ClosePixelpassHelp => {
|
||||||
state.pixelpass_help_open = false;
|
state.pixelpass_help_open = false;
|
||||||
}
|
}
|
||||||
AppMessage::DismissClockSkewWarning => {
|
|
||||||
state.clock_skew_warning = None;
|
|
||||||
}
|
|
||||||
AppMessage::ClockSkewWarningTick => {
|
|
||||||
clear_expired_clock_skew_warning(state, std::time::Instant::now());
|
|
||||||
}
|
|
||||||
AppMessage::SelectRoomLayout(layout) => {
|
AppMessage::SelectRoomLayout(layout) => {
|
||||||
state.config.room_layout = layout;
|
state.config.room_layout = layout;
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -2408,37 +2363,6 @@ fn format_duration(total_secs: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_clock_skew_duration(skew_secs: u64) -> String {
|
|
||||||
let minutes = skew_secs.max(1).saturating_add(59) / 60;
|
|
||||||
if minutes == 1 {
|
|
||||||
"1 minute".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{minutes} minutes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_clock_skew_warning(
|
|
||||||
state: &mut AppState,
|
|
||||||
skew_secs: u64,
|
|
||||||
peer_ahead: bool,
|
|
||||||
now: std::time::Instant,
|
|
||||||
) {
|
|
||||||
state.clock_skew_warning = Some(ClockSkewBanner {
|
|
||||||
skew_secs,
|
|
||||||
peer_ahead,
|
|
||||||
expires_at: now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_expired_clock_skew_warning(state: &mut AppState, now: std::time::Instant) {
|
|
||||||
if state
|
|
||||||
.clock_skew_warning
|
|
||||||
.is_some_and(|warning| now >= warning.expires_at)
|
|
||||||
{
|
|
||||||
state.clock_skew_warning = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First 8 characters of an id string for compact display. Panic-free: takes
|
/// First 8 characters of an id string for compact display. Panic-free: takes
|
||||||
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
|
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
|
||||||
/// render (security finding S1) — ids are long ASCII hex today, but this guards
|
/// render (security finding S1) — ids are long ASCII hex today, but this guards
|
||||||
@@ -2630,7 +2554,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
let nickname_input = column![
|
let nickname_input = column![
|
||||||
text("Nickname").size(14).color(color_subtext),
|
text("Nickname").size(14).color(color_subtext),
|
||||||
vertical_space(4.0),
|
vertical_space(4.0),
|
||||||
context_input("Enter nickname...", &state.name)
|
text_input("Enter nickname...", &state.name)
|
||||||
.on_input(AppMessage::NicknameChanged)
|
.on_input(AppMessage::NicknameChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
@@ -2639,7 +2563,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
// Optional cosmetic room label (W7) above the Create button: it rides in the
|
// Optional cosmetic room label (W7) above the Create button: it rides in the
|
||||||
// minted ticket so everyone who joins inherits "in <name>". Enter also creates.
|
// minted ticket so everyone who joins inherits "in <name>". Enter also creates.
|
||||||
let create_group = column![
|
let create_group = column![
|
||||||
context_input("Room name (optional)", &state.room_name_input)
|
text_input("Room name (optional)", &state.room_name_input)
|
||||||
.on_input(AppMessage::RoomNameChanged)
|
.on_input(AppMessage::RoomNameChanged)
|
||||||
.on_submit(AppMessage::CreatePressed)
|
.on_submit(AppMessage::CreatePressed)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
@@ -2655,7 +2579,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
let join_group = column![
|
let join_group = column![
|
||||||
text("Join Existing Room").size(14).color(color_subtext),
|
text("Join Existing Room").size(14).color(color_subtext),
|
||||||
vertical_space(4.0),
|
vertical_space(4.0),
|
||||||
context_input("Paste room ticket here...", &state.ticket_input)
|
text_input("Paste room ticket here...", &state.ticket_input)
|
||||||
.on_input(AppMessage::TicketInputChanged)
|
.on_input(AppMessage::TicketInputChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(10),
|
.padding(10),
|
||||||
@@ -2881,7 +2805,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
};
|
};
|
||||||
friend_rows = friend_rows.push(
|
friend_rows = friend_rows.push(
|
||||||
row![
|
row![
|
||||||
context_input("name", &f.name)
|
text_input("name", &f.name)
|
||||||
.on_input(move |v| AppMessage::RenameFriend(fid, v))
|
.on_input(move |v| AppMessage::RenameFriend(fid, v))
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(6)
|
.padding(6)
|
||||||
@@ -2915,13 +2839,13 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let add_form = column![
|
let add_form = column![
|
||||||
context_input("Friend's node ID", &state.friend_add_id)
|
text_input("Friend's node ID", &state.friend_add_id)
|
||||||
.on_input(AppMessage::FriendAddIdChanged)
|
.on_input(AppMessage::FriendAddIdChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(6),
|
.padding(6),
|
||||||
vertical_space(6.0),
|
vertical_space(6.0),
|
||||||
row![
|
row![
|
||||||
context_input("Name (optional)", &state.friend_add_name)
|
text_input("Name (optional)", &state.friend_add_name)
|
||||||
.on_input(AppMessage::FriendAddNameChanged)
|
.on_input(AppMessage::FriendAddNameChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(6)
|
.padding(6)
|
||||||
@@ -3170,7 +3094,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
horizontal_space(),
|
horizontal_space(),
|
||||||
validation_widget,
|
validation_widget,
|
||||||
].spacing(6).align_y(iced::alignment::Vertical::Center),
|
].spacing(6).align_y(iced::alignment::Vertical::Center),
|
||||||
context_input("Default (embedded)...", path)
|
text_input("Default (embedded)...", path)
|
||||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
@@ -3541,15 +3465,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
// The ID line exposes the full value in a locked selectable field while
|
// The ID line shows a short form (iced text isn't selectable) plus a Copy
|
||||||
// keeping the one-click Copy button for fast whole-ID copy.
|
// button that puts the FULL node id on the clipboard, so it's shareable.
|
||||||
let id_row: Element<AppMessage> = match state.self_node_id.clone() {
|
let id_row: Element<AppMessage> = match state.self_node_id.clone() {
|
||||||
Some(full) => row![
|
Some(full) => row![
|
||||||
text("ID:").size(13).color(color_text),
|
text(format!("ID: {id_display}")).size(13).color(color_text),
|
||||||
locked_value(&full, AppMessage::Noop)
|
|
||||||
.width(iced::Length::Fixed(260.0))
|
|
||||||
.size(13)
|
|
||||||
.padding(4),
|
|
||||||
button(
|
button(
|
||||||
row![
|
row![
|
||||||
icon(IconKind::Copy, 13.0, color_text),
|
icon(IconKind::Copy, 13.0, color_text),
|
||||||
@@ -3558,7 +3478,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.spacing(5)
|
.spacing(5)
|
||||||
.align_y(iced::alignment::Vertical::Center)
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
)
|
)
|
||||||
.on_press(AppMessage::CopyText(full.clone()))
|
.on_press(AppMessage::CopyText(full))
|
||||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||||
.padding(6),
|
.padding(6),
|
||||||
]
|
]
|
||||||
@@ -3844,10 +3764,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
|
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
|
||||||
.size(11).color(color_subtext),
|
.size(11).color(color_subtext),
|
||||||
row![
|
row![
|
||||||
context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
|
text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
|
||||||
.on_input(AppMessage::GameMapExeChanged)
|
.on_input(AppMessage::GameMapExeChanged)
|
||||||
.width(iced::Length::Fill),
|
.width(iced::Length::Fill),
|
||||||
context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
|
text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
|
||||||
.on_input(AppMessage::GameMapNameChanged)
|
.on_input(AppMessage::GameMapNameChanged)
|
||||||
.width(iced::Length::Fill),
|
.width(iced::Length::Fill),
|
||||||
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
||||||
@@ -4111,15 +4031,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
text(format!("My ID: {}", short_id(&state.self_id)))
|
text(format!("My ID: {}", short_id(&state.self_id)))
|
||||||
.size(14)
|
.size(14)
|
||||||
.color(color_subtext),
|
.color(color_subtext),
|
||||||
row![
|
|
||||||
text("Ticket:").size(12).color(color_subtext),
|
|
||||||
locked_value(&state.ticket, AppMessage::Noop)
|
|
||||||
.width(iced::Length::Fixed(260.0))
|
|
||||||
.size(12)
|
|
||||||
.padding(4),
|
|
||||||
]
|
|
||||||
.spacing(6)
|
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
|
||||||
button(
|
button(
|
||||||
row![
|
row![
|
||||||
icon(IconKind::Copy, 14.0, color_text),
|
icon(IconKind::Copy, 14.0, color_text),
|
||||||
@@ -4671,9 +4582,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let body = selectable_rich_text(spans)
|
let body = rich_text(spans)
|
||||||
.on_link_click(AppMessage::OpenUrl)
|
.on_link_click(AppMessage::OpenUrl)
|
||||||
.selection_color(color_blue)
|
|
||||||
.width(iced::Length::Fill);
|
.width(iced::Length::Fill);
|
||||||
// Small avatar keyed on the sender's id (falls back to name); the
|
// Small avatar keyed on the sender's id (falls back to name); the
|
||||||
// " (You)" suffix on our own echoes is stripped for clean initials.
|
// " (You)" suffix on our own echoes is stripped for clean initials.
|
||||||
@@ -4856,7 +4766,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.on_press(AppMessage::PickAttachmentFile)
|
.on_press(AppMessage::PickAttachmentFile)
|
||||||
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
||||||
.padding(8),
|
.padding(8),
|
||||||
context_input("Message the room…", &state.chat_input)
|
text_input("Message the room…", &state.chat_input)
|
||||||
.on_input(AppMessage::ChatInputChanged)
|
.on_input(AppMessage::ChatInputChanged)
|
||||||
.on_submit(AppMessage::ChatSubmit)
|
.on_submit(AppMessage::ChatSubmit)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
@@ -4977,43 +4887,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let clock_skew_banner: Element<'_, AppMessage> =
|
|
||||||
if let Some(warning) = state.clock_skew_warning {
|
|
||||||
let direction = if warning.peer_ahead { "ahead" } else { "behind" };
|
|
||||||
let skew = format_clock_skew_duration(warning.skew_secs);
|
|
||||||
let copy = format!(
|
|
||||||
"A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)."
|
|
||||||
);
|
|
||||||
column![
|
|
||||||
vertical_space(10.0),
|
|
||||||
container(
|
|
||||||
row![
|
|
||||||
icon(IconKind::Clock, 16.0, color_yellow),
|
|
||||||
text(copy).size(12).color(color_text).width(iced::Length::Fill),
|
|
||||||
button(text("Dismiss").size(12))
|
|
||||||
.on_press(AppMessage::DismissClockSkewWarning)
|
|
||||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
|
||||||
.padding(6),
|
|
||||||
]
|
|
||||||
.spacing(10)
|
|
||||||
.align_y(iced::alignment::Vertical::Center)
|
|
||||||
)
|
|
||||||
.padding(10)
|
|
||||||
.width(iced::Length::Fill)
|
|
||||||
.style(move |_theme: &Theme| container::Style {
|
|
||||||
text_color: Some(color_text),
|
|
||||||
background: Some(Background::Color(Color { a: 0.14, ..color_yellow })),
|
|
||||||
border: Border { color: color_yellow, width: 1.0, radius: 8.0.into() },
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
]
|
|
||||||
.into()
|
|
||||||
} else {
|
|
||||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
|
||||||
};
|
|
||||||
|
|
||||||
let room = container(
|
let room = container(
|
||||||
column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body]
|
column![top_bar, header_container, vertical_space(12.0), body]
|
||||||
)
|
)
|
||||||
.padding(15)
|
.padding(15)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
@@ -6347,11 +6222,10 @@ impl Program<AppMessage> for Icon {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration,
|
attachment_default_name, format_duration, initial_window_position, reconnect_attempt_chime,
|
||||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
reconnected_chime, set_peer_gate_config, set_peer_volume_config, update, AppConfig,
|
||||||
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig,
|
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX,
|
||||||
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner,
|
UiEvent,
|
||||||
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
|
|
||||||
};
|
};
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
|
|
||||||
@@ -6518,11 +6392,6 @@ mod tests {
|
|||||||
state.share_audio_dropped = true;
|
state.share_audio_dropped = true;
|
||||||
state.share_audio_app_active = true;
|
state.share_audio_app_active = true;
|
||||||
state.share_app_audio_supported = false;
|
state.share_app_audio_supported = false;
|
||||||
state.clock_skew_warning = Some(ClockSkewBanner {
|
|
||||||
skew_secs: 180,
|
|
||||||
peer_ahead: true,
|
|
||||||
expires_at: now,
|
|
||||||
});
|
|
||||||
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
||||||
|
|
||||||
state.reset_room_state();
|
state.reset_room_state();
|
||||||
@@ -6550,7 +6419,6 @@ mod tests {
|
|||||||
assert!(!state.share_audio_dropped);
|
assert!(!state.share_audio_dropped);
|
||||||
assert!(!state.share_audio_app_active);
|
assert!(!state.share_audio_app_active);
|
||||||
assert!(state.share_app_audio_supported, "reset is optimistic by default");
|
assert!(state.share_app_audio_supported, "reset is optimistic by default");
|
||||||
assert!(state.clock_skew_warning.is_none());
|
|
||||||
|
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
||||||
@@ -6561,61 +6429,6 @@ mod tests {
|
|||||||
panic!("clip player did not stop during room reset");
|
panic!("clip player did not stop during room reset");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_warning_shows_dismisses_and_expires() {
|
|
||||||
let mut state = AppState::default();
|
|
||||||
let now = std::time::Instant::now();
|
|
||||||
|
|
||||||
show_clock_skew_warning(&mut state, 181, true, now);
|
|
||||||
let warning = state.clock_skew_warning.expect("warning should be visible");
|
|
||||||
assert_eq!(warning.skew_secs, 181);
|
|
||||||
assert!(warning.peer_ahead);
|
|
||||||
assert_eq!(
|
|
||||||
warning.expires_at,
|
|
||||||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS)
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = update(&mut state, AppMessage::DismissClockSkewWarning);
|
|
||||||
assert!(state.clock_skew_warning.is_none());
|
|
||||||
|
|
||||||
show_clock_skew_warning(&mut state, 240, false, now);
|
|
||||||
clear_expired_clock_skew_warning(
|
|
||||||
&mut state,
|
|
||||||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS - 1),
|
|
||||||
);
|
|
||||||
assert!(state.clock_skew_warning.is_some());
|
|
||||||
clear_expired_clock_skew_warning(
|
|
||||||
&mut state,
|
|
||||||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
|
|
||||||
);
|
|
||||||
assert!(state.clock_skew_warning.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_ui_event_populates_banner() {
|
|
||||||
let mut state = AppState::default();
|
|
||||||
let _ = update(
|
|
||||||
&mut state,
|
|
||||||
AppMessage::UiEventReceived(UiEvent::ClockSkewWarning {
|
|
||||||
skew_secs: 121,
|
|
||||||
peer_ahead: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let warning = state.clock_skew_warning.expect("event should show banner");
|
|
||||||
assert_eq!(warning.skew_secs, 121);
|
|
||||||
assert!(!warning.peer_ahead);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_duration_rounds_up_to_minutes() {
|
|
||||||
assert_eq!(format_clock_skew_duration(0), "1 minute");
|
|
||||||
assert_eq!(format_clock_skew_duration(1), "1 minute");
|
|
||||||
assert_eq!(format_clock_skew_duration(60), "1 minute");
|
|
||||||
assert_eq!(format_clock_skew_duration(61), "2 minutes");
|
|
||||||
assert_eq!(format_clock_skew_duration(181), "4 minutes");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn share_picker_startup_window_is_guarded() {
|
fn share_picker_startup_window_is_guarded() {
|
||||||
// P3-1: between confirming the picker and the core's ScreenShareStarted,
|
// P3-1: between confirming the picker and the core's ScreenShareStarted,
|
||||||
|
|||||||
@@ -109,80 +109,6 @@ pub enum CoreCommand {
|
|||||||
SetGameProcessMap(std::collections::BTreeMap<String, String>),
|
SetGameProcessMap(std::collections::BTreeMap<String, String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DeliveryClass {
|
|
||||||
Reliable,
|
|
||||||
BestEffort,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
|
||||||
/// actions are Reliable (must land). The only high-frequency commands are the
|
|
||||||
/// continuous audio sliders, where dropping intermediate values is harmless;
|
|
||||||
/// those are BestEffort.
|
|
||||||
pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|
||||||
match cmd {
|
|
||||||
CoreCommand::SetPeerVolume(_, _)
|
|
||||||
| CoreCommand::SetPeerPan(_, _)
|
|
||||||
| CoreCommand::SetPeerGate(_, _)
|
|
||||||
| CoreCommand::SetPeerEq(_, _)
|
|
||||||
| CoreCommand::SetInputVolume(_)
|
|
||||||
| CoreCommand::SetOutputVolume(_)
|
|
||||||
| CoreCommand::SetNoiseGateThreshold(_) => DeliveryClass::BestEffort,
|
|
||||||
|
|
||||||
CoreCommand::Join {
|
|
||||||
name: _,
|
|
||||||
ticket: _,
|
|
||||||
room_name: _,
|
|
||||||
input_device: _,
|
|
||||||
output_device: _,
|
|
||||||
echo_cancellation: _,
|
|
||||||
avatar: _,
|
|
||||||
}
|
|
||||||
| CoreCommand::Leave
|
|
||||||
| CoreCommand::Shutdown
|
|
||||||
| CoreCommand::ToggleMute
|
|
||||||
| CoreCommand::SetAvatar(_)
|
|
||||||
| CoreCommand::ToggleDeafen
|
|
||||||
| CoreCommand::SetPttMode(_)
|
|
||||||
| CoreCommand::SetPttActive(_)
|
|
||||||
| CoreCommand::SetPeerMuted(_, _)
|
|
||||||
| CoreCommand::SetMicMonitor {
|
|
||||||
enabled: _,
|
|
||||||
input_device: _,
|
|
||||||
}
|
|
||||||
| CoreCommand::SetNetworkMode(_)
|
|
||||||
| CoreCommand::SetRecording(_)
|
|
||||||
| CoreCommand::SetRecordingMode(_)
|
|
||||||
| CoreCommand::SendChat(_)
|
|
||||||
| CoreCommand::SendChatFile {
|
|
||||||
text: _,
|
|
||||||
attachment: _,
|
|
||||||
data: _,
|
|
||||||
}
|
|
||||||
| CoreCommand::FetchAttachment {
|
|
||||||
from: _,
|
|
||||||
attachment: _,
|
|
||||||
}
|
|
||||||
| CoreCommand::SetPixelpassPath(_)
|
|
||||||
| CoreCommand::ListAudioApps
|
|
||||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
|
||||||
| CoreCommand::StopScreenShare
|
|
||||||
| CoreCommand::ViewShare(_)
|
|
||||||
| CoreCommand::RegenerateIdentity
|
|
||||||
| CoreCommand::AddFriend {
|
|
||||||
id: _,
|
|
||||||
name: _,
|
|
||||||
addr: _,
|
|
||||||
}
|
|
||||||
| CoreCommand::RemoveFriend(_)
|
|
||||||
| CoreCommand::RenameFriend(_, _)
|
|
||||||
| CoreCommand::SetPresenceMode(_)
|
|
||||||
| CoreCommand::SetGamePresenceEnabled(_)
|
|
||||||
| CoreCommand::SetGameOverride(_)
|
|
||||||
| CoreCommand::SetGameProcessMap(_) => DeliveryClass::Reliable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum UiEvent {
|
pub enum UiEvent {
|
||||||
RoomJoined { ticket: String, self_id: String },
|
RoomJoined { ticket: String, self_id: String },
|
||||||
@@ -237,10 +163,6 @@ pub enum UiEvent {
|
|||||||
/// run viewers currently hear silence. The UI shows a transient warning while
|
/// run viewers currently hear silence. The UI shows a transient warning while
|
||||||
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
||||||
ShareAudioActive(bool),
|
ShareAudioActive(bool),
|
||||||
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
|
||||||
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
|
||||||
/// sender-stamped timestamp relative to this machine's clock.
|
|
||||||
ClockSkewWarning { skew_secs: u64, peer_ahead: bool },
|
|
||||||
/// Our node identity (W7): the current node id string, and whether it is
|
/// Our node identity (W7): the current node id string, and whether it is
|
||||||
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
||||||
/// `persisted = false` means the key file couldn't be read/written and we're
|
/// `persisted = false` means the key file couldn't be read/written and we're
|
||||||
@@ -273,60 +195,3 @@ pub enum UiEvent {
|
|||||||
ShutdownComplete,
|
ShutdownComplete,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{delivery_class, CoreCommand, DeliveryClass};
|
|
||||||
use crate::audio::eq::EqSettings;
|
|
||||||
use crate::presence::PresenceMode;
|
|
||||||
use iroh::{EndpointId, SecretKey};
|
|
||||||
|
|
||||||
fn endpoint_id() -> EndpointId {
|
|
||||||
SecretKey::generate().public()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn continuous_audio_controls_are_best_effort() {
|
|
||||||
let peer = endpoint_id();
|
|
||||||
let commands = [
|
|
||||||
CoreCommand::SetPeerVolume(peer, 0.7),
|
|
||||||
CoreCommand::SetPeerPan(peer, -0.2),
|
|
||||||
CoreCommand::SetPeerGate(peer, 0.1),
|
|
||||||
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
|
||||||
CoreCommand::SetInputVolume(0.8),
|
|
||||||
CoreCommand::SetOutputVolume(0.9),
|
|
||||||
CoreCommand::SetNoiseGateThreshold(0.02),
|
|
||||||
];
|
|
||||||
|
|
||||||
for cmd in commands {
|
|
||||||
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn discrete_user_actions_are_reliable() {
|
|
||||||
let peer = endpoint_id();
|
|
||||||
let commands = [
|
|
||||||
CoreCommand::ToggleMute,
|
|
||||||
CoreCommand::SetPttActive(false),
|
|
||||||
CoreCommand::Leave,
|
|
||||||
CoreCommand::RegenerateIdentity,
|
|
||||||
CoreCommand::Join {
|
|
||||||
name: "Peer".to_string(),
|
|
||||||
ticket: "create".to_string(),
|
|
||||||
room_name: "Room".to_string(),
|
|
||||||
input_device: None,
|
|
||||||
output_device: None,
|
|
||||||
echo_cancellation: true,
|
|
||||||
avatar: crate::avatar::Avatar::default(),
|
|
||||||
},
|
|
||||||
CoreCommand::SetPeerMuted(peer, true),
|
|
||||||
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
|
||||||
CoreCommand::SendChat("hello".to_string()),
|
|
||||||
];
|
|
||||||
|
|
||||||
for cmd in commands {
|
|
||||||
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+33
-80
@@ -11,7 +11,7 @@ use crate::network::{
|
|||||||
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
||||||
gossip::IrohGossipState,
|
gossip::IrohGossipState,
|
||||||
};
|
};
|
||||||
use crate::core::messages::{CoreCommand, DeliveryClass, UiEvent, delivery_class};
|
use crate::core::messages::{CoreCommand, UiEvent};
|
||||||
use crate::core::recovery::RecoveryCoordinator;
|
use crate::core::recovery::RecoveryCoordinator;
|
||||||
|
|
||||||
use crate::config::{NetworkMode, RecordingMode};
|
use crate::config::{NetworkMode, RecordingMode};
|
||||||
@@ -26,44 +26,38 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub struct CoreController {
|
pub struct CoreController {
|
||||||
reliable_tx: mpsc::UnboundedSender<CoreCommand>,
|
cmd_tx: mpsc::Sender<CoreCommand>,
|
||||||
besteffort_tx: mpsc::Sender<CoreCommand>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoreController {
|
impl CoreController {
|
||||||
pub fn new(ui_tx: mpsc::Sender<UiEvent>) -> Self {
|
pub fn new(ui_tx: mpsc::Sender<UiEvent>) -> Self {
|
||||||
let (reliable_tx, reliable_rx) = mpsc::unbounded_channel();
|
let (cmd_tx, cmd_rx) = mpsc::channel(100);
|
||||||
let (besteffort_tx, besteffort_rx) = mpsc::channel(100);
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
|
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
crate::log_msg("Starting core network loop in dedicated Tokio runtime");
|
crate::log_msg("Starting core network loop in dedicated Tokio runtime");
|
||||||
if let Err(e) = run_core_loop(reliable_rx, besteffort_rx, ui_tx).await {
|
if let Err(e) = run_core_loop(cmd_rx, ui_tx).await {
|
||||||
crate::log_msg(&format!("App core loop failed: {:?}", e));
|
crate::log_msg(&format!("App core loop failed: {:?}", e));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Self { reliable_tx, besteffort_tx }
|
Self { cmd_tx }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue a command for the core loop. Reliable commands only fail when the
|
/// Queue a command for the core loop, best-effort. Returns `true` if it was
|
||||||
/// core loop is dead; best-effort slider commands keep today's bounded
|
/// accepted, `false` if the channel is full or closed. (We return a plain
|
||||||
/// try-send behavior. (We return a plain bool rather than the channel's
|
/// bool rather than the channel's `Result` so the bulky `CoreCommand` isn't
|
||||||
/// `Result` so the bulky `CoreCommand` isn't carried back by value in every
|
/// carried back by value in every caller's error type.)
|
||||||
/// caller's error type.)
|
|
||||||
pub fn send(&self, cmd: CoreCommand) -> bool {
|
pub fn send(&self, cmd: CoreCommand) -> bool {
|
||||||
match delivery_class(&cmd) {
|
self.cmd_tx.try_send(cmd).is_ok()
|
||||||
DeliveryClass::Reliable => self.reliable_tx.send(cmd).is_ok(),
|
|
||||||
DeliveryClass::BestEffort => self.besteffort_tx.try_send(cmd).is_ok(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clone the command sender for asynchronous one-shot sends that should wait
|
/// Clone the command sender for asynchronous one-shot sends that should wait
|
||||||
/// for channel capacity instead of failing immediately on a full queue.
|
/// for channel capacity instead of failing immediately on a full queue.
|
||||||
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
|
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
|
||||||
self.besteffort_tx.clone()
|
self.cmd_tx.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,17 +294,6 @@ fn apply_volume(frame: &mut [i16], vol: f32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the listener's per-peer volume for the audio sender id currently being
|
|
||||||
/// mixed. The map key must be the same `EndpointId` used for the jitter buffer.
|
|
||||||
fn apply_peer_volume(
|
|
||||||
frame: &mut [i16],
|
|
||||||
peer_id: EndpointId,
|
|
||||||
volumes: &HashMap<EndpointId, f32>,
|
|
||||||
) {
|
|
||||||
let vol = volumes.get(&peer_id).copied().unwrap_or(1.0);
|
|
||||||
apply_volume(frame, vol);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalized RMS level of a frame in `[0.0, 1.0]` (32768 = full scale), for the
|
/// Normalized RMS level of a frame in `[0.0, 1.0]` (32768 = full scale), for the
|
||||||
/// UI level meter. An empty frame reads as 0.0.
|
/// UI level meter. An empty frame reads as 0.0.
|
||||||
fn frame_level(frame: &[i16]) -> f32 {
|
fn frame_level(frame: &[i16]) -> f32 {
|
||||||
@@ -958,8 +941,7 @@ async fn probe_friends_once(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_core_loop(
|
async fn run_core_loop(
|
||||||
mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
|
mut cmd_rx: mpsc::Receiver<CoreCommand>,
|
||||||
mut besteffort_rx: mpsc::Receiver<CoreCommand>,
|
|
||||||
ui_tx: mpsc::Sender<UiEvent>,
|
ui_tx: mpsc::Sender<UiEvent>,
|
||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new();
|
let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new();
|
||||||
@@ -1092,22 +1074,34 @@ async fn run_core_loop(
|
|||||||
// Join, cleared on Leave.
|
// Join, cleared on Leave.
|
||||||
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
||||||
Arc::new(std::sync::Mutex::new(None));
|
Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let presence_rate_limiter =
|
||||||
|
Arc::new(std::sync::Mutex::new(crate::presence::PresenceRateLimiter::default()));
|
||||||
|
|
||||||
// Reply policy for the idle friends listener (B2): answer friends only, never
|
// Reply policy for the idle friends listener (B2): answer friends only, never
|
||||||
// while invisible (`should_answer`), and report our current gathering so a friend
|
// while invisible (`should_answer`), and report our current gathering so a friend
|
||||||
// can one-click join. Reads the shared snapshots, so it stays correct as they
|
// can one-click join. Rate-limits allowed friends before building a reply, so a
|
||||||
// change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
|
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
|
||||||
// across one). Built once and handed to every `build_net_stack`.
|
// shared snapshots, so it stays correct as they change and survives a network-stack
|
||||||
|
// rebuild. Pure-sync (no awaits, no lock held across one). Built once and handed
|
||||||
|
// to every `build_net_stack`.
|
||||||
let friends_handler: crate::presence_net::Handler = {
|
let friends_handler: crate::presence_net::Handler = {
|
||||||
let friends = friends.clone();
|
let friends = friends.clone();
|
||||||
let presence_mode = presence_mode.clone();
|
let presence_mode = presence_mode.clone();
|
||||||
let current_room = current_room.clone();
|
let current_room = current_room.clone();
|
||||||
|
let presence_rate_limiter = presence_rate_limiter.clone();
|
||||||
Arc::new(move |from| {
|
Arc::new(move |from| {
|
||||||
let mode = *presence_mode.lock().unwrap();
|
let mode = *presence_mode.lock().unwrap();
|
||||||
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if !presence_rate_limiter
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.allow(from, std::time::Instant::now())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let room = current_room.lock().unwrap().clone();
|
let room = current_room.lock().unwrap().clone();
|
||||||
Some(crate::presence::ControlMsg::Pong { room })
|
Some(crate::presence::ControlMsg::Pong { room })
|
||||||
})
|
})
|
||||||
@@ -1162,12 +1156,7 @@ async fn run_core_loop(
|
|||||||
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
loop {
|
loop {
|
||||||
let cmd = tokio::select! {
|
let cmd = tokio::select! {
|
||||||
biased;
|
maybe_cmd = cmd_rx.recv() => match maybe_cmd {
|
||||||
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
|
|
||||||
Some(cmd) => cmd,
|
|
||||||
None => break,
|
|
||||||
},
|
|
||||||
maybe_cmd = besteffort_rx.recv() => match maybe_cmd {
|
|
||||||
Some(cmd) => cmd,
|
Some(cmd) => cmd,
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
@@ -1730,7 +1719,8 @@ async fn run_core_loop(
|
|||||||
peer_noise_gates.remove(&peer_id);
|
peer_noise_gates.remove(&peer_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_peer_volume(&mut frame, peer_id, ¤t_volumes);
|
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
|
||||||
|
apply_volume(&mut frame, vol);
|
||||||
|
|
||||||
let eq_settings = current_eq
|
let eq_settings = current_eq
|
||||||
.get(&peer_id)
|
.get(&peer_id)
|
||||||
@@ -2102,19 +2092,6 @@ async fn run_core_loop(
|
|||||||
attachment,
|
attachment,
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
RoomEvent::ClockSkewSuspected { author, skew_ms } => {
|
|
||||||
crate::log_msg(&format!(
|
|
||||||
"Clock skew suspected for authenticated gossip author={} skew_ms={skew_ms}",
|
|
||||||
crate::short_id(&author.to_string())
|
|
||||||
));
|
|
||||||
let skew_secs = skew_ms.unsigned_abs().saturating_add(999) / 1000;
|
|
||||||
let _ = ui_tx_events
|
|
||||||
.send(UiEvent::ClockSkewWarning {
|
|
||||||
skew_secs,
|
|
||||||
peer_ahead: skew_ms > 0,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
RoomEvent::PeerConnectionLost(peer_id) => {
|
RoomEvent::PeerConnectionLost(peer_id) => {
|
||||||
// Transient drop: do NOT tear down the peer. Its audio
|
// Transient drop: do NOT tear down the peer. Its audio
|
||||||
// supervisor stays alive and keeps redialing the
|
// supervisor stays alive and keeps redialing the
|
||||||
@@ -2811,9 +2788,9 @@ async fn run_core_loop(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, frame_level,
|
admit_retained, apply_volume, audio_datagram_len_ok, frame_level, mix_frames,
|
||||||
mix_frames, mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono,
|
mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, KnownPeers,
|
||||||
KnownPeers, MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
|
MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
|
||||||
MIC_LEVEL_REPORT_SAMPLES,
|
MIC_LEVEL_REPORT_SAMPLES,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3073,30 +3050,6 @@ mod tests {
|
|||||||
assert_eq!(frame, vec![2000, -2000]);
|
assert_eq!(frame, vec![2000, -2000]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_volume_map_scales_the_matching_audio_peer_frame() {
|
|
||||||
let peer = iroh::SecretKey::generate().public();
|
|
||||||
let other_peer = iroh::SecretKey::generate().public();
|
|
||||||
let volumes = std::collections::HashMap::from([(peer, 0.5), (other_peer, 2.0)]);
|
|
||||||
let mut frame = vec![100, -200, 300, -400];
|
|
||||||
|
|
||||||
apply_peer_volume(&mut frame, peer, &volumes);
|
|
||||||
|
|
||||||
assert_eq!(frame, vec![50, -100, 150, -200]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_volume_map_defaults_to_unity_when_audio_peer_key_is_unmatched() {
|
|
||||||
let ui_peer = iroh::SecretKey::generate().public();
|
|
||||||
let audio_peer = iroh::SecretKey::generate().public();
|
|
||||||
let volumes = std::collections::HashMap::from([(ui_peer, 0.5)]);
|
|
||||||
let mut frame = vec![100, -200, 300, -400];
|
|
||||||
|
|
||||||
apply_peer_volume(&mut frame, audio_peer, &volumes);
|
|
||||||
|
|
||||||
assert_eq!(frame, vec![100, -200, 300, -400]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn three_peers_sum_without_saturation() {
|
fn three_peers_sum_without_saturation() {
|
||||||
let a = vec![10, 20];
|
let a = vec![10, 20];
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ pub mod discovery;
|
|||||||
pub mod hotkeys;
|
pub mod hotkeys;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod widget;
|
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|||||||
+3
-204
@@ -152,100 +152,6 @@ fn prune_stale_mutations(
|
|||||||
seen.retain(|_, last_ts| *last_ts >= floor);
|
seen.retain(|_, last_ts| *last_ts >= floor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Three signed, out-of-window payloads inside one minute is enough to distinguish
|
|
||||||
/// a persistently skewed clock from a single delayed gossip frame without making
|
|
||||||
/// the user wait long. Repeats are suppressed for five minutes per author.
|
|
||||||
const CLOCK_SKEW_OBSERVATION_WINDOW_MS: u64 = 60_000;
|
|
||||||
const CLOCK_SKEW_WARNING_THRESHOLD: usize = 3;
|
|
||||||
const CLOCK_SKEW_COOLDOWN_MS: u64 = 5 * 60_000;
|
|
||||||
const CLOCK_SKEW_AUTHORS_SOFT_CAP: usize = 256;
|
|
||||||
const CLOCK_SKEW_AUTHORS_HARD_CAP: usize = 512;
|
|
||||||
const CLOCK_SKEW_AUTHOR_TTL_MS: u64 = CLOCK_SKEW_COOLDOWN_MS;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct ClockSkewWarning {
|
|
||||||
author: EndpointId,
|
|
||||||
/// Positive means the peer's sender-stamped clock is ahead of ours.
|
|
||||||
skew_ms: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct ClockSkewMonitor {
|
|
||||||
authors: HashMap<EndpointId, ClockSkewAuthorState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct ClockSkewAuthorState {
|
|
||||||
observed_at: Vec<u64>,
|
|
||||||
last_seen_ms: u64,
|
|
||||||
last_warned_ms: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClockSkewMonitor {
|
|
||||||
fn observe(
|
|
||||||
&mut self,
|
|
||||||
author: EndpointId,
|
|
||||||
skew_ms: i64,
|
|
||||||
now_ms: u64,
|
|
||||||
) -> Option<ClockSkewWarning> {
|
|
||||||
if self.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP {
|
|
||||||
self.prune_stale_authors(now_ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
let warning = {
|
|
||||||
let state = self.authors.entry(author).or_default();
|
|
||||||
state.last_seen_ms = now_ms;
|
|
||||||
let floor = now_ms.saturating_sub(CLOCK_SKEW_OBSERVATION_WINDOW_MS);
|
|
||||||
state.observed_at.retain(|ts| *ts >= floor);
|
|
||||||
state.observed_at.push(now_ms);
|
|
||||||
if state.observed_at.len() > CLOCK_SKEW_WARNING_THRESHOLD {
|
|
||||||
let excess = state.observed_at.len() - CLOCK_SKEW_WARNING_THRESHOLD;
|
|
||||||
state.observed_at.drain(0..excess);
|
|
||||||
}
|
|
||||||
|
|
||||||
let threshold_met = state.observed_at.len() >= CLOCK_SKEW_WARNING_THRESHOLD;
|
|
||||||
let in_cooldown = state
|
|
||||||
.last_warned_ms
|
|
||||||
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
|
|
||||||
if threshold_met && !in_cooldown {
|
|
||||||
state.last_warned_ms = Some(now_ms);
|
|
||||||
Some(ClockSkewWarning { author, skew_ms })
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if self.authors.len() > CLOCK_SKEW_AUTHORS_HARD_CAP {
|
|
||||||
self.drop_oldest_authors();
|
|
||||||
}
|
|
||||||
|
|
||||||
warning
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prune_stale_authors(&mut self, now_ms: u64) {
|
|
||||||
let stale_before = now_ms.saturating_sub(CLOCK_SKEW_AUTHOR_TTL_MS);
|
|
||||||
self.authors.retain(|_, state| {
|
|
||||||
let last_warning_live = state
|
|
||||||
.last_warned_ms
|
|
||||||
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
|
|
||||||
last_warning_live || state.last_seen_ms >= stale_before
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drop_oldest_authors(&mut self) {
|
|
||||||
let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
|
||||||
let mut by_age: Vec<_> = self
|
|
||||||
.authors
|
|
||||||
.iter()
|
|
||||||
.map(|(author, state)| (*author, state.last_seen_ms))
|
|
||||||
.collect();
|
|
||||||
by_age.sort_by_key(|(_, last_seen_ms)| *last_seen_ms);
|
|
||||||
for (author, _) in by_age.into_iter().take(remove_count) {
|
|
||||||
self.authors.remove(&author);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum number of distinct peers we hold in a room roster at once.
|
/// Maximum number of distinct peers we hold in a room roster at once.
|
||||||
///
|
///
|
||||||
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
||||||
@@ -507,7 +413,6 @@ impl RoomState for IrohGossipState {
|
|||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||||
let mut state_mutations_seen = HashMap::new();
|
let mut state_mutations_seen = HashMap::new();
|
||||||
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
|
||||||
|
|
||||||
// Broadcast initial state
|
// Broadcast initial state
|
||||||
let initial_payload = {
|
let initial_payload = {
|
||||||
@@ -548,28 +453,9 @@ impl RoomState for IrohGossipState {
|
|||||||
// action: a forged/stale payload is dropped here
|
// action: a forged/stale payload is dropped here
|
||||||
// so it can't impersonate, evict, or poison
|
// so it can't impersonate, evict, or poison
|
||||||
// presence/address-book (security S2).
|
// presence/address-book (security S2).
|
||||||
let received_now_ms = now_millis();
|
if let Err(reason) =
|
||||||
if let Err(reason) = verify_gossip(
|
verify_gossip(&payload, &topic_bytes, now_millis(), GOSSIP_FRESHNESS_MS)
|
||||||
&payload,
|
{
|
||||||
&topic_bytes,
|
|
||||||
received_now_ms,
|
|
||||||
GOSSIP_FRESHNESS_MS,
|
|
||||||
) {
|
|
||||||
if reason == GossipReject::OutOfWindow {
|
|
||||||
let skew_ms = payload.ts as i64 - received_now_ms as i64;
|
|
||||||
if let Some(warning) = clock_skew_monitor.observe(
|
|
||||||
payload.author,
|
|
||||||
skew_ms,
|
|
||||||
received_now_ms,
|
|
||||||
) {
|
|
||||||
let _ = event_tx
|
|
||||||
.send(RoomEvent::ClockSkewSuspected {
|
|
||||||
author: warning.author,
|
|
||||||
skew_ms: warning.skew_ms,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
"Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}",
|
"Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}",
|
||||||
payload.author, reason
|
payload.author, reason
|
||||||
@@ -1064,93 +950,6 @@ mod tests {
|
|||||||
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
|
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_monitor_single_drop_does_not_warn() {
|
|
||||||
let author = fresh_id();
|
|
||||||
let mut monitor = ClockSkewMonitor::default();
|
|
||||||
|
|
||||||
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_monitor_three_drops_in_window_warn_once() {
|
|
||||||
let author = fresh_id();
|
|
||||||
let mut monitor = ClockSkewMonitor::default();
|
|
||||||
|
|
||||||
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
|
|
||||||
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
|
|
||||||
assert_eq!(
|
|
||||||
monitor.observe(author, -123_000, 69_999),
|
|
||||||
Some(ClockSkewWarning { author, skew_ms: -123_000 })
|
|
||||||
);
|
|
||||||
assert_eq!(monitor.observe(author, -124_000, 70_000), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_monitor_cooldown_suppresses_repeats() {
|
|
||||||
let author = fresh_id();
|
|
||||||
let mut monitor = ClockSkewMonitor::default();
|
|
||||||
|
|
||||||
assert_eq!(monitor.observe(author, 121_000, 0), None);
|
|
||||||
assert_eq!(monitor.observe(author, 122_000, 10_000), None);
|
|
||||||
assert!(monitor.observe(author, 123_000, 20_000).is_some());
|
|
||||||
|
|
||||||
assert_eq!(monitor.observe(author, 124_000, 30_000), None);
|
|
||||||
assert_eq!(monitor.observe(author, 125_000, 310_000), None);
|
|
||||||
assert_eq!(monitor.observe(author, 126_000, 319_000), None);
|
|
||||||
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
|
|
||||||
assert_eq!(
|
|
||||||
monitor.observe(author, 128_000, 320_000),
|
|
||||||
Some(ClockSkewWarning { author, skew_ms: 128_000 })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_monitor_tracks_distinct_authors_independently() {
|
|
||||||
let a = fresh_id();
|
|
||||||
let b = fresh_id();
|
|
||||||
let mut monitor = ClockSkewMonitor::default();
|
|
||||||
|
|
||||||
assert_eq!(monitor.observe(a, -121_000, 0), None);
|
|
||||||
assert_eq!(monitor.observe(a, -121_000, 1_000), None);
|
|
||||||
assert_eq!(monitor.observe(b, 121_000, 0), None);
|
|
||||||
assert_eq!(monitor.observe(b, 121_000, 1_000), None);
|
|
||||||
assert_eq!(
|
|
||||||
monitor.observe(b, 121_000, 2_000),
|
|
||||||
Some(ClockSkewWarning { author: b, skew_ms: 121_000 })
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
monitor.observe(a, -121_000, 2_000),
|
|
||||||
Some(ClockSkewWarning { author: a, skew_ms: -121_000 })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_monitor_prunes_stale_authors_when_over_cap() {
|
|
||||||
let mut monitor = ClockSkewMonitor::default();
|
|
||||||
for _ in 0..=CLOCK_SKEW_AUTHORS_SOFT_CAP {
|
|
||||||
assert_eq!(monitor.observe(fresh_id(), -121_000, 1), None);
|
|
||||||
}
|
|
||||||
assert!(monitor.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
|
||||||
|
|
||||||
let current = fresh_id();
|
|
||||||
assert_eq!(
|
|
||||||
monitor.observe(current, -121_000, CLOCK_SKEW_AUTHOR_TTL_MS + 2),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(monitor.authors.len(), 1);
|
|
||||||
assert!(monitor.authors.contains_key(¤t));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_skew_monitor_hard_cap_bounds_fresh_author_growth() {
|
|
||||||
let mut monitor = ClockSkewMonitor::default();
|
|
||||||
for now_ms in 0..(CLOCK_SKEW_AUTHORS_HARD_CAP as u64 + 10) {
|
|
||||||
let _ = monitor.observe(fresh_id(), -121_000, now_ms);
|
|
||||||
assert!(monitor.authors.len() <= CLOCK_SKEW_AUTHORS_HARD_CAP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_endpoint_addr_caps_address_count() {
|
fn sanitize_endpoint_addr_caps_address_count() {
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|||||||
@@ -106,10 +106,6 @@ pub enum RoomEvent {
|
|||||||
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
||||||
/// reconnect path the way it used to.
|
/// reconnect path the way it used to.
|
||||||
PeerConnectionLost(EndpointId),
|
PeerConnectionLost(EndpointId),
|
||||||
/// A validly signed gossip payload was rejected only because its timestamp is
|
|
||||||
/// outside the replay-protection window. The peer is not in the roster yet,
|
|
||||||
/// so this surfaces as a room-level warning instead of a peer-card state.
|
|
||||||
ClockSkewSuspected { author: EndpointId, skew_ms: i64 },
|
|
||||||
/// A peer sent a room text-chat message. Carries the sender's id, their
|
/// A peer sent a room text-chat message. Carries the sender's id, their
|
||||||
/// display name (embedded so it shows even without a presence entry), the
|
/// display name (embedded so it shows even without a presence entry), the
|
||||||
/// text, and a sender-stamped millisecond timestamp.
|
/// text, and a sender-stamped millisecond timestamp.
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
use crate::friends::FriendStore;
|
use crate::friends::FriendStore;
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Maximum immediate presence replies to one friend before throttling. Normal
|
||||||
|
/// presence polling is once per minute, so this only catches repeated/manual or
|
||||||
|
/// abusive probes while still allowing a short burst after app startup.
|
||||||
|
pub const PRESENCE_RATE_LIMIT_BURST: u32 = 4;
|
||||||
|
|
||||||
|
/// Refill one presence-reply token per friend at this cadence.
|
||||||
|
pub const PRESENCE_RATE_LIMIT_REFILL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// The user's presence posture — how reachable they are to friends while idle.
|
/// The user's presence posture — how reachable they are to friends while idle.
|
||||||
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
||||||
@@ -100,6 +110,48 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
|
|||||||
mode.answers_pings() && friends.contains(from)
|
mode.answers_pings() && friends.contains(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RateBucket {
|
||||||
|
tokens: u32,
|
||||||
|
last_refill: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-friend limiter for inbound presence pings. It is intentionally keyed by
|
||||||
|
/// the authenticated connection id, not payload data. Callers should only invoke
|
||||||
|
/// it after [`should_answer`] passes, so strangers do not consume memory here.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct PresenceRateLimiter {
|
||||||
|
buckets: HashMap<EndpointId, RateBucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresenceRateLimiter {
|
||||||
|
/// Return whether `from` may receive a presence reply at `now`.
|
||||||
|
///
|
||||||
|
/// This is a token bucket: each friend starts with a small burst and regains
|
||||||
|
/// one token every [`PRESENCE_RATE_LIMIT_REFILL`]. A denied probe should be
|
||||||
|
/// answered with no data, matching the listener's "reveal nothing" policy.
|
||||||
|
pub fn allow(&mut self, from: EndpointId, now: Instant) -> bool {
|
||||||
|
let bucket = self.buckets.entry(from).or_insert(RateBucket {
|
||||||
|
tokens: PRESENCE_RATE_LIMIT_BURST,
|
||||||
|
last_refill: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
let elapsed = now.saturating_duration_since(bucket.last_refill);
|
||||||
|
let refill = elapsed.as_secs() / PRESENCE_RATE_LIMIT_REFILL.as_secs();
|
||||||
|
if refill > 0 {
|
||||||
|
let refill = refill.min(u32::MAX as u64) as u32;
|
||||||
|
bucket.tokens = PRESENCE_RATE_LIMIT_BURST.min(bucket.tokens.saturating_add(refill));
|
||||||
|
bucket.last_refill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if bucket.tokens == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bucket.tokens -= 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What we learned about a friend from a successful ping reply.
|
/// What we learned about a friend from a successful ping reply.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FriendPresence {
|
pub enum FriendPresence {
|
||||||
@@ -177,6 +229,36 @@ mod tests {
|
|||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_rate_limiter_allows_a_small_burst_then_refills() {
|
||||||
|
let mut limiter = PresenceRateLimiter::default();
|
||||||
|
let friend = id();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||||
|
assert!(limiter.allow(friend, now));
|
||||||
|
}
|
||||||
|
assert!(!limiter.allow(friend, now));
|
||||||
|
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL - Duration::from_millis(1)));
|
||||||
|
|
||||||
|
assert!(limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||||
|
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_rate_limiter_is_per_peer() {
|
||||||
|
let mut limiter = PresenceRateLimiter::default();
|
||||||
|
let a = id();
|
||||||
|
let b = id();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||||
|
assert!(limiter.allow(a, now));
|
||||||
|
}
|
||||||
|
assert!(!limiter.allow(a, now));
|
||||||
|
assert!(limiter.allow(b, now));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn presence_mode_flags() {
|
fn presence_mode_flags() {
|
||||||
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
||||||
|
|||||||
@@ -1,943 +0,0 @@
|
|||||||
use iced::advanced::clipboard::{self, Clipboard};
|
|
||||||
use iced::advanced::layout;
|
|
||||||
use iced::advanced::mouse;
|
|
||||||
use iced::advanced::overlay;
|
|
||||||
use iced::advanced::renderer;
|
|
||||||
use iced::advanced::text;
|
|
||||||
use iced::advanced::widget::tree::{self, Tree};
|
|
||||||
use iced::advanced::widget::{self, Widget};
|
|
||||||
use iced::advanced::{Layout, Shell};
|
|
||||||
use iced::widget::text_input;
|
|
||||||
use iced::{
|
|
||||||
alignment, Background, Border, Color, Element, Event, Length, Padding,
|
|
||||||
Pixels, Point, Rectangle, Shadow, Size, Vector,
|
|
||||||
};
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Edit {
|
|
||||||
pub value: String,
|
|
||||||
pub cursor: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
|
|
||||||
let value = text_input::Value::new(value);
|
|
||||||
let (start, end) = normalized_range(&value, start, end);
|
|
||||||
|
|
||||||
(start != end).then(|| value.select(start, end).to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cut_selection(
|
|
||||||
value: &str,
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
) -> (Edit, Option<String>) {
|
|
||||||
let mut value = text_input::Value::new(value);
|
|
||||||
let (start, end) = normalized_range(&value, start, end);
|
|
||||||
|
|
||||||
if start == end {
|
|
||||||
return (
|
|
||||||
Edit {
|
|
||||||
value: value.to_string(),
|
|
||||||
cursor: start,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = value.select(start, end).to_string();
|
|
||||||
value.remove_many(start, end);
|
|
||||||
|
|
||||||
(
|
|
||||||
Edit {
|
|
||||||
value: value.to_string(),
|
|
||||||
cursor: start,
|
|
||||||
},
|
|
||||||
Some(selected),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
|
|
||||||
let mut value = text_input::Value::new(value);
|
|
||||||
let (start, end) = normalized_range(&value, start, end);
|
|
||||||
let clip = text_input::Value::new(clip);
|
|
||||||
let cursor = start + clip.len();
|
|
||||||
|
|
||||||
if start != end {
|
|
||||||
value.remove_many(start, end);
|
|
||||||
}
|
|
||||||
value.insert_many(start, clip);
|
|
||||||
|
|
||||||
Edit {
|
|
||||||
value: value.to_string(),
|
|
||||||
cursor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_all_range(value: &str) -> (usize, usize) {
|
|
||||||
let value = text_input::Value::new(value);
|
|
||||||
|
|
||||||
(0, value.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalized_range(
|
|
||||||
value: &text_input::Value,
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
) -> (usize, usize) {
|
|
||||||
let len = value.len();
|
|
||||||
|
|
||||||
(start.min(end).min(len), start.max(end).min(len))
|
|
||||||
}
|
|
||||||
|
|
||||||
type InputStyleFn<'a, Theme> =
|
|
||||||
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
|
||||||
|
|
||||||
pub fn context_input<'a, Message, Theme, Renderer>(
|
|
||||||
placeholder: &str,
|
|
||||||
value: &str,
|
|
||||||
) -> ContextInput<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Message: Clone + 'a,
|
|
||||||
Theme: text_input::Catalog + 'a,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
ContextInput::new(placeholder, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn locked_value<'a, Message, Theme, Renderer>(
|
|
||||||
value: &str,
|
|
||||||
noop: Message,
|
|
||||||
) -> ContextInput<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Message: Clone + 'a,
|
|
||||||
Theme: text_input::Catalog + 'a,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
ContextInput::new("", value)
|
|
||||||
.on_input(move |_| noop.clone())
|
|
||||||
.locked(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ContextInput<
|
|
||||||
'a,
|
|
||||||
Message,
|
|
||||||
Theme = iced::Theme,
|
|
||||||
Renderer = iced::Renderer,
|
|
||||||
> where
|
|
||||||
Theme: text_input::Catalog,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
|
||||||
value: String,
|
|
||||||
is_secure: bool,
|
|
||||||
locked: bool,
|
|
||||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
|
||||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
|
||||||
style: Option<InputStyleFn<'a, Theme>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, Message, Theme, Renderer>
|
|
||||||
ContextInput<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Message: Clone + 'a,
|
|
||||||
Theme: text_input::Catalog + 'a,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
pub fn new(placeholder: &str, value: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
input: text_input::TextInput::new(placeholder, value),
|
|
||||||
value: value.to_owned(),
|
|
||||||
is_secure: false,
|
|
||||||
locked: false,
|
|
||||||
on_input: None,
|
|
||||||
on_paste: None,
|
|
||||||
style: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
|
|
||||||
self.input = self.input.id(id);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn secure(mut self, is_secure: bool) -> Self {
|
|
||||||
self.is_secure = is_secure;
|
|
||||||
self.input = self.input.secure(is_secure);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn locked(mut self, yes: bool) -> Self {
|
|
||||||
self.locked = yes;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_input(
|
|
||||||
mut self,
|
|
||||||
on_input: impl Fn(String) -> Message + 'a,
|
|
||||||
) -> Self {
|
|
||||||
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
|
|
||||||
Rc::new(on_input);
|
|
||||||
let input_callback = Rc::clone(&on_input);
|
|
||||||
|
|
||||||
self.input =
|
|
||||||
self.input.on_input(move |value| input_callback.as_ref()(value));
|
|
||||||
self.on_input = Some(on_input);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_submit(mut self, message: Message) -> Self {
|
|
||||||
self.input = self.input.on_submit(message);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
|
|
||||||
self.input = self.input.on_submit_maybe(message);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_paste(
|
|
||||||
mut self,
|
|
||||||
on_paste: impl Fn(String) -> Message + 'a,
|
|
||||||
) -> Self {
|
|
||||||
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
|
|
||||||
Rc::new(on_paste);
|
|
||||||
let paste_callback = Rc::clone(&on_paste);
|
|
||||||
|
|
||||||
self.input =
|
|
||||||
self.input.on_paste(move |value| paste_callback.as_ref()(value));
|
|
||||||
self.on_paste = Some(on_paste);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn font(mut self, font: Renderer::Font) -> Self {
|
|
||||||
self.input = self.input.font(font);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
|
|
||||||
self.input = self.input.icon(icon);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
|
||||||
self.input = self.input.width(width);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
|
||||||
self.input = self.input.padding(padding);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
|
||||||
self.input = self.input.size(size);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn line_height(
|
|
||||||
mut self,
|
|
||||||
line_height: impl Into<text::LineHeight>,
|
|
||||||
) -> Self {
|
|
||||||
self.input = self.input.line_height(line_height);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn align_x(
|
|
||||||
mut self,
|
|
||||||
alignment: impl Into<alignment::Horizontal>,
|
|
||||||
) -> Self {
|
|
||||||
self.input = self.input.align_x(alignment);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn style(
|
|
||||||
mut self,
|
|
||||||
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
|
|
||||||
) -> Self
|
|
||||||
where
|
|
||||||
Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
|
|
||||||
{
|
|
||||||
let style: InputStyleFn<'a, Theme> = Rc::new(style);
|
|
||||||
let input_style = Rc::clone(&style);
|
|
||||||
|
|
||||||
self.input = self
|
|
||||||
.input
|
|
||||||
.style(move |theme, status| input_style.as_ref()(theme, status));
|
|
||||||
self.style = Some(style);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
|
||||||
self.input = self.input.class(class);
|
|
||||||
self.style = None;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct ContextInputState {
|
|
||||||
menu: Option<MenuState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
struct MenuState {
|
|
||||||
anchor: Point,
|
|
||||||
selection: (usize, usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
|
||||||
for ContextInput<'_, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Message: Clone,
|
|
||||||
Theme: text_input::Catalog,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
fn tag(&self) -> tree::Tag {
|
|
||||||
tree::Tag::of::<ContextInputState>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state(&self) -> tree::State {
|
|
||||||
tree::State::new(ContextInputState::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn children(&self) -> Vec<Tree> {
|
|
||||||
vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn diff(&self, tree: &mut Tree) {
|
|
||||||
if tree.children.is_empty() {
|
|
||||||
tree.children
|
|
||||||
.push(Tree::new(&self.input as &dyn Widget<_, _, _>));
|
|
||||||
} else {
|
|
||||||
tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
|
|
||||||
tree.children.truncate(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn size(&self) -> Size<Length> {
|
|
||||||
Widget::size(&self.input)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn size_hint(&self) -> Size<Length> {
|
|
||||||
Widget::size_hint(&self.input)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(
|
|
||||||
&mut self,
|
|
||||||
tree: &mut Tree,
|
|
||||||
renderer: &Renderer,
|
|
||||||
limits: &layout::Limits,
|
|
||||||
) -> layout::Node {
|
|
||||||
Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn operate(
|
|
||||||
&mut self,
|
|
||||||
tree: &mut Tree,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
renderer: &Renderer,
|
|
||||||
operation: &mut dyn widget::Operation,
|
|
||||||
) {
|
|
||||||
Widget::operate(
|
|
||||||
&mut self.input,
|
|
||||||
&mut tree.children[0],
|
|
||||||
layout,
|
|
||||||
renderer,
|
|
||||||
operation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(
|
|
||||||
&mut self,
|
|
||||||
tree: &mut Tree,
|
|
||||||
event: &Event,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
renderer: &Renderer,
|
|
||||||
clipboard: &mut dyn Clipboard,
|
|
||||||
shell: &mut Shell<'_, Message>,
|
|
||||||
viewport: &Rectangle,
|
|
||||||
) {
|
|
||||||
let right_click_on_input = matches!(
|
|
||||||
event,
|
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
|
||||||
) && cursor.is_over(layout.bounds());
|
|
||||||
|
|
||||||
if right_click_on_input {
|
|
||||||
let value = text_input::Value::new(&self.value);
|
|
||||||
let input_state = tree.children[0]
|
|
||||||
.state
|
|
||||||
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
|
|
||||||
let selection = match input_state.cursor().state(&value) {
|
|
||||||
text_input::cursor::State::Index(index) => {
|
|
||||||
let index = index.min(value.len());
|
|
||||||
(index, index)
|
|
||||||
}
|
|
||||||
text_input::cursor::State::Selection { start, end } => {
|
|
||||||
normalized_range(&value, start, end)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tree.state.downcast_mut::<ContextInputState>().menu =
|
|
||||||
cursor.position().map(|anchor| MenuState {
|
|
||||||
anchor,
|
|
||||||
selection,
|
|
||||||
});
|
|
||||||
|
|
||||||
shell.capture_event();
|
|
||||||
shell.request_redraw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget::update(
|
|
||||||
&mut self.input,
|
|
||||||
&mut tree.children[0],
|
|
||||||
event,
|
|
||||||
layout,
|
|
||||||
cursor,
|
|
||||||
renderer,
|
|
||||||
clipboard,
|
|
||||||
shell,
|
|
||||||
viewport,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(
|
|
||||||
&self,
|
|
||||||
tree: &Tree,
|
|
||||||
renderer: &mut Renderer,
|
|
||||||
theme: &Theme,
|
|
||||||
style: &renderer::Style,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
viewport: &Rectangle,
|
|
||||||
) {
|
|
||||||
Widget::draw(
|
|
||||||
&self.input,
|
|
||||||
&tree.children[0],
|
|
||||||
renderer,
|
|
||||||
theme,
|
|
||||||
style,
|
|
||||||
layout,
|
|
||||||
cursor,
|
|
||||||
viewport,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mouse_interaction(
|
|
||||||
&self,
|
|
||||||
tree: &Tree,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
viewport: &Rectangle,
|
|
||||||
renderer: &Renderer,
|
|
||||||
) -> mouse::Interaction {
|
|
||||||
Widget::mouse_interaction(
|
|
||||||
&self.input,
|
|
||||||
&tree.children[0],
|
|
||||||
layout,
|
|
||||||
cursor,
|
|
||||||
viewport,
|
|
||||||
renderer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn overlay<'a>(
|
|
||||||
&'a mut self,
|
|
||||||
tree: &'a mut Tree,
|
|
||||||
_layout: Layout<'a>,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
_translation: Vector,
|
|
||||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
|
||||||
let Tree {
|
|
||||||
state, children, ..
|
|
||||||
} = tree;
|
|
||||||
let menu = &mut state.downcast_mut::<ContextInputState>().menu;
|
|
||||||
|
|
||||||
if menu.is_none() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let input_state = children[0]
|
|
||||||
.state
|
|
||||||
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
|
|
||||||
|
|
||||||
Some(overlay::Element::new(Box::new(ContextMenuOverlay {
|
|
||||||
menu,
|
|
||||||
input_state,
|
|
||||||
value: &self.value,
|
|
||||||
is_secure: self.is_secure,
|
|
||||||
locked: self.locked,
|
|
||||||
on_input: self.on_input.clone(),
|
|
||||||
on_paste: self.on_paste.clone(),
|
|
||||||
style: self.style.clone(),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, Message, Theme, Renderer>
|
|
||||||
From<ContextInput<'a, Message, Theme, Renderer>>
|
|
||||||
for Element<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Message: Clone + 'a,
|
|
||||||
Theme: text_input::Catalog + 'a,
|
|
||||||
Renderer: text::Renderer + 'a,
|
|
||||||
{
|
|
||||||
fn from(
|
|
||||||
input: ContextInput<'a, Message, Theme, Renderer>,
|
|
||||||
) -> Element<'a, Message, Theme, Renderer> {
|
|
||||||
Element::new(input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContextMenuOverlay<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Theme: text_input::Catalog,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
menu: &'a mut Option<MenuState>,
|
|
||||||
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
|
||||||
value: &'a str,
|
|
||||||
is_secure: bool,
|
|
||||||
locked: bool,
|
|
||||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
|
||||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
|
||||||
style: Option<InputStyleFn<'a, Theme>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum MenuAction {
|
|
||||||
Cut,
|
|
||||||
Copy,
|
|
||||||
Paste,
|
|
||||||
SelectAll,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MenuAction {
|
|
||||||
const ALL: [Self; 4] = [
|
|
||||||
Self::Cut,
|
|
||||||
Self::Copy,
|
|
||||||
Self::Paste,
|
|
||||||
Self::SelectAll,
|
|
||||||
];
|
|
||||||
|
|
||||||
fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Cut => "Cut",
|
|
||||||
Self::Copy => "Copy",
|
|
||||||
Self::Paste => "Paste",
|
|
||||||
Self::SelectAll => "Select All",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MENU_WIDTH: f32 = 136.0;
|
|
||||||
const ITEM_HEIGHT: f32 = 28.0;
|
|
||||||
const TEXT_SIZE: f32 = 13.0;
|
|
||||||
const MENU_PADDING_X: f32 = 10.0;
|
|
||||||
|
|
||||||
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
|
|
||||||
for ContextMenuOverlay<'_, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Theme: text_input::Catalog,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
|
|
||||||
let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
|
|
||||||
let Some(menu) = self.menu.as_ref() else {
|
|
||||||
return layout::Node::new(Size::ZERO);
|
|
||||||
};
|
|
||||||
let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
|
|
||||||
let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));
|
|
||||||
|
|
||||||
layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(
|
|
||||||
&self,
|
|
||||||
renderer: &mut Renderer,
|
|
||||||
theme: &Theme,
|
|
||||||
_style: &renderer::Style,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
) {
|
|
||||||
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
|
||||||
let hovered_style =
|
|
||||||
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
let viewport = Rectangle::INFINITE;
|
|
||||||
|
|
||||||
renderer.fill_quad(
|
|
||||||
renderer::Quad {
|
|
||||||
bounds,
|
|
||||||
border: Border {
|
|
||||||
radius: 5.0.into(),
|
|
||||||
width: 1.0,
|
|
||||||
color: active_style.border.color,
|
|
||||||
},
|
|
||||||
shadow: Shadow {
|
|
||||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
|
|
||||||
offset: Vector::new(0.0, 4.0),
|
|
||||||
blur_radius: 10.0,
|
|
||||||
},
|
|
||||||
..renderer::Quad::default()
|
|
||||||
},
|
|
||||||
active_style.background,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
|
|
||||||
let item_bounds = item_bounds(bounds, index);
|
|
||||||
let enabled = self.enabled(action);
|
|
||||||
let hovered = enabled && cursor.is_over(item_bounds);
|
|
||||||
|
|
||||||
if hovered {
|
|
||||||
renderer.fill_quad(
|
|
||||||
renderer::Quad {
|
|
||||||
bounds: item_bounds,
|
|
||||||
border: Border {
|
|
||||||
radius: 3.0.into(),
|
|
||||||
..Border::default()
|
|
||||||
},
|
|
||||||
..renderer::Quad::default()
|
|
||||||
},
|
|
||||||
Background::Color(hovered_style.selection),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.fill_text(
|
|
||||||
text::Text {
|
|
||||||
content: action.label().to_owned(),
|
|
||||||
bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
|
|
||||||
size: Pixels(TEXT_SIZE),
|
|
||||||
line_height: text::LineHeight::default(),
|
|
||||||
font: renderer.default_font(),
|
|
||||||
align_x: text::Alignment::Default,
|
|
||||||
align_y: alignment::Vertical::Center,
|
|
||||||
shaping: text::Shaping::Advanced,
|
|
||||||
wrapping: text::Wrapping::default(),
|
|
||||||
},
|
|
||||||
Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
|
|
||||||
if enabled {
|
|
||||||
active_style.value
|
|
||||||
} else {
|
|
||||||
disabled_color(active_style.value)
|
|
||||||
},
|
|
||||||
viewport,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(
|
|
||||||
&mut self,
|
|
||||||
event: &Event,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
clipboard: &mut dyn Clipboard,
|
|
||||||
shell: &mut Shell<'_, Message>,
|
|
||||||
) {
|
|
||||||
match event {
|
|
||||||
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
||||||
key: iced::keyboard::Key::Named(
|
|
||||||
iced::keyboard::key::Named::Escape,
|
|
||||||
),
|
|
||||||
..
|
|
||||||
}) => {
|
|
||||||
self.close(shell);
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
|
||||||
let Some(position) = cursor.position() else {
|
|
||||||
self.close(shell);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
|
|
||||||
if !bounds.contains(position) {
|
|
||||||
self.close(shell);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(action) = self.hit_action(bounds, position) {
|
|
||||||
if self.enabled(action) {
|
|
||||||
self.perform(action, clipboard, shell);
|
|
||||||
}
|
|
||||||
self.close(shell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
|
||||||
let should_close = cursor
|
|
||||||
.position()
|
|
||||||
.is_none_or(|position| !layout.bounds().contains(position));
|
|
||||||
|
|
||||||
if should_close {
|
|
||||||
self.close(shell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mouse_interaction(
|
|
||||||
&self,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
) -> mouse::Interaction {
|
|
||||||
let Some(position) = cursor.position() else {
|
|
||||||
return mouse::Interaction::default();
|
|
||||||
};
|
|
||||||
|
|
||||||
if self.hit_action(layout.bounds(), position).is_some() {
|
|
||||||
mouse::Interaction::Pointer
|
|
||||||
} else {
|
|
||||||
mouse::Interaction::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Theme: text_input::Catalog,
|
|
||||||
Renderer: text::Renderer,
|
|
||||||
{
|
|
||||||
fn enabled(&self, action: MenuAction) -> bool {
|
|
||||||
let Some(menu) = self.menu.as_ref() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let has_selection = menu.selection.0 != menu.selection.1;
|
|
||||||
let has_value = !text_input::Value::new(self.value).is_empty();
|
|
||||||
|
|
||||||
menu_action_enabled(
|
|
||||||
action,
|
|
||||||
has_selection,
|
|
||||||
has_value,
|
|
||||||
self.is_secure,
|
|
||||||
self.locked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hit_action(
|
|
||||||
&self,
|
|
||||||
bounds: Rectangle,
|
|
||||||
position: Point,
|
|
||||||
) -> Option<MenuAction> {
|
|
||||||
if !bounds.contains(position) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;
|
|
||||||
|
|
||||||
MenuAction::ALL.get(index).copied()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn perform(
|
|
||||||
&mut self,
|
|
||||||
action: MenuAction,
|
|
||||||
clipboard: &mut dyn Clipboard,
|
|
||||||
shell: &mut Shell<'_, Message>,
|
|
||||||
) {
|
|
||||||
let Some(menu) = self.menu.as_ref().copied() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (start, end) = menu.selection;
|
|
||||||
|
|
||||||
match action {
|
|
||||||
MenuAction::Cut => {
|
|
||||||
let (edit, selected) = cut_selection(self.value, start, end);
|
|
||||||
|
|
||||||
if let Some(selected) = selected {
|
|
||||||
clipboard.write(clipboard::Kind::Standard, selected);
|
|
||||||
self.publish_edit(edit, shell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MenuAction::Copy => {
|
|
||||||
if let Some(selected) = copy_selection(self.value, start, end) {
|
|
||||||
clipboard.write(clipboard::Kind::Standard, selected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MenuAction::Paste => {
|
|
||||||
let clip = clipboard
|
|
||||||
.read(clipboard::Kind::Standard)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_control())
|
|
||||||
.collect::<String>();
|
|
||||||
let edit = paste(self.value, start, end, &clip);
|
|
||||||
|
|
||||||
self.publish_paste(edit, shell);
|
|
||||||
}
|
|
||||||
MenuAction::SelectAll => {
|
|
||||||
let (start, end) = select_all_range(self.value);
|
|
||||||
|
|
||||||
self.input_state.select_range(start, end);
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
|
||||||
if let Some(on_input) = &self.on_input {
|
|
||||||
self.input_state.move_cursor_to(edit.cursor);
|
|
||||||
shell.publish(on_input.as_ref()(edit.value));
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
|
||||||
self.input_state.move_cursor_to(edit.cursor);
|
|
||||||
|
|
||||||
if let Some(on_paste) = &self.on_paste {
|
|
||||||
shell.publish(on_paste.as_ref()(edit.value));
|
|
||||||
} else if let Some(on_input) = &self.on_input {
|
|
||||||
shell.publish(on_input.as_ref()(edit.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&mut self, shell: &mut Shell<'_, Message>) {
|
|
||||||
*self.menu = None;
|
|
||||||
shell.capture_event();
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
|
|
||||||
Rectangle {
|
|
||||||
x: menu_bounds.x + 3.0,
|
|
||||||
y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
|
|
||||||
width: menu_bounds.width - 6.0,
|
|
||||||
height: ITEM_HEIGHT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_style<Theme: text_input::Catalog>(
|
|
||||||
theme: &Theme,
|
|
||||||
style: Option<&InputStyleFn<'_, Theme>>,
|
|
||||||
status: text_input::Status,
|
|
||||||
) -> text_input::Style {
|
|
||||||
if let Some(style) = style {
|
|
||||||
style.as_ref()(theme, status)
|
|
||||||
} else {
|
|
||||||
let class = <Theme as text_input::Catalog>::default();
|
|
||||||
|
|
||||||
theme.style(&class, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disabled_color(color: Color) -> Color {
|
|
||||||
Color {
|
|
||||||
a: color.a * 0.45,
|
|
||||||
..color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn menu_action_enabled(
|
|
||||||
action: MenuAction,
|
|
||||||
has_selection: bool,
|
|
||||||
has_value: bool,
|
|
||||||
is_secure: bool,
|
|
||||||
locked: bool,
|
|
||||||
) -> bool {
|
|
||||||
match action {
|
|
||||||
MenuAction::Cut => has_selection && !is_secure && !locked,
|
|
||||||
MenuAction::Copy => has_selection && !is_secure,
|
|
||||||
MenuAction::Paste => !locked,
|
|
||||||
MenuAction::SelectAll => has_value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn copy_selection_returns_middle_substring_and_empty_none() {
|
|
||||||
assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
|
|
||||||
assert_eq!(copy_selection("abcdef", 3, 3), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cut_selection_removes_range_and_copies_selection() {
|
|
||||||
let (edit, clip) = cut_selection("abcdef", 2, 5);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
edit,
|
|
||||||
Edit {
|
|
||||||
value: "abf".to_owned(),
|
|
||||||
cursor: 2,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(clip, Some("cde".to_owned()));
|
|
||||||
|
|
||||||
let (edit, clip) = cut_selection("abcdef", 3, 3);
|
|
||||||
assert_eq!(
|
|
||||||
edit,
|
|
||||||
Edit {
|
|
||||||
value: "abcdef".to_owned(),
|
|
||||||
cursor: 3,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(clip, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_replaces_selection_or_inserts_at_cursor() {
|
|
||||||
assert_eq!(
|
|
||||||
paste("abcdef", 2, 5, "XY"),
|
|
||||||
Edit {
|
|
||||||
value: "abXYf".to_owned(),
|
|
||||||
cursor: 4,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
paste("abcdef", 3, 3, "XY"),
|
|
||||||
Edit {
|
|
||||||
value: "abcXYdef".to_owned(),
|
|
||||||
cursor: 5,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn select_all_range_uses_grapheme_length() {
|
|
||||||
assert_eq!(select_all_range(""), (0, 0));
|
|
||||||
assert_eq!(select_all_range("abé🦀"), (0, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unicode_selection_boundaries_are_grapheme_correct() {
|
|
||||||
assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));
|
|
||||||
|
|
||||||
let (edit, clip) = cut_selection("aé🦀z", 2, 3);
|
|
||||||
assert_eq!(clip, Some("🦀".to_owned()));
|
|
||||||
assert_eq!(
|
|
||||||
edit,
|
|
||||||
Edit {
|
|
||||||
value: "aéz".to_owned(),
|
|
||||||
cursor: 2,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
paste("aéz", 2, 2, "🦀"),
|
|
||||||
Edit {
|
|
||||||
value: "aé🦀z".to_owned(),
|
|
||||||
cursor: 3,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn locked_menu_allows_copy_and_select_all_only() {
|
|
||||||
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
|
|
||||||
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
|
|
||||||
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
|
|
||||||
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
|
|
||||||
|
|
||||||
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
|
|
||||||
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod context_input;
|
|
||||||
pub mod selectable_text;
|
|
||||||
@@ -1,890 +0,0 @@
|
|||||||
use iced::advanced::clipboard::{self, Clipboard};
|
|
||||||
use iced::advanced::layout;
|
|
||||||
use iced::advanced::mouse;
|
|
||||||
use iced::advanced::renderer;
|
|
||||||
use iced::advanced::text::{self as advanced_text, Paragraph, Span};
|
|
||||||
use iced::advanced::widget::tree::{self, Tree};
|
|
||||||
use iced::advanced::widget::Widget;
|
|
||||||
use iced::advanced::{Layout, Shell};
|
|
||||||
use iced::widget::text::{
|
|
||||||
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn,
|
|
||||||
Wrapping,
|
|
||||||
};
|
|
||||||
use iced::{
|
|
||||||
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point,
|
|
||||||
Rectangle, Size, Vector, keyboard,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DRAG_THRESHOLD: f32 = 3.0;
|
|
||||||
const HIT_SEARCH_STEPS: usize = 24;
|
|
||||||
|
|
||||||
// Offsets here are paragraph-global BYTE offsets. `Paragraph::hit_test` returns
|
|
||||||
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
|
|
||||||
// offset WITHIN its buffer line — it discards the line number. That equals the
|
|
||||||
// global byte offset only when the text is a single logical line. Chat bodies
|
|
||||||
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n`
|
|
||||||
// and `\r`) into a space and collapses whitespace, so a stored message can never
|
|
||||||
// contain a newline. If that sanitizer ever starts preserving newlines, this
|
|
||||||
// widget's per-line offsets would stop being global and selection/copy across
|
|
||||||
// lines would break — revisit then.
|
|
||||||
|
|
||||||
pub fn selected_substring(
|
|
||||||
text: &str,
|
|
||||||
anchor: usize,
|
|
||||||
cursor: usize,
|
|
||||||
) -> Option<String> {
|
|
||||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
|
||||||
|
|
||||||
(start != end).then(|| text[start..end].to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_all(text: &str) -> (usize, usize) {
|
|
||||||
(0, text.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalized_byte_range(
|
|
||||||
text: &str,
|
|
||||||
anchor: usize,
|
|
||||||
cursor: usize,
|
|
||||||
) -> (usize, usize) {
|
|
||||||
let start = clamp_to_char_boundary(text, anchor.min(cursor));
|
|
||||||
let end = clamp_to_char_boundary(text, anchor.max(cursor));
|
|
||||||
|
|
||||||
(start.min(end), start.max(end))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clamp_to_char_boundary(text: &str, offset: usize) -> usize {
|
|
||||||
let mut offset = offset.min(text.len());
|
|
||||||
|
|
||||||
while offset > 0 && !text.is_char_boundary(offset) {
|
|
||||||
offset -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
offset
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selectable_rich_text<'a, Link, Message, Theme, Renderer>(
|
|
||||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
|
||||||
) -> SelectableRichText<'a, Link, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Link: Clone + 'static,
|
|
||||||
Theme: Catalog + 'a,
|
|
||||||
Renderer: advanced_text::Renderer,
|
|
||||||
Renderer::Font: 'a,
|
|
||||||
{
|
|
||||||
SelectableRichText::with_spans(spans)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SelectableRichText<
|
|
||||||
'a,
|
|
||||||
Link,
|
|
||||||
Message,
|
|
||||||
Theme = iced::Theme,
|
|
||||||
Renderer = iced::Renderer,
|
|
||||||
> where
|
|
||||||
Link: Clone + 'static,
|
|
||||||
Theme: Catalog,
|
|
||||||
Renderer: advanced_text::Renderer,
|
|
||||||
{
|
|
||||||
spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>,
|
|
||||||
size: Option<Pixels>,
|
|
||||||
line_height: LineHeight,
|
|
||||||
width: Length,
|
|
||||||
height: Length,
|
|
||||||
font: Option<Renderer::Font>,
|
|
||||||
align_x: Alignment,
|
|
||||||
align_y: alignment::Vertical,
|
|
||||||
wrapping: Wrapping,
|
|
||||||
class: Theme::Class<'a>,
|
|
||||||
hovered_link: Option<usize>,
|
|
||||||
on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>,
|
|
||||||
selection_color: Color,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, Link, Message, Theme, Renderer>
|
|
||||||
SelectableRichText<'a, Link, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Link: Clone + 'static,
|
|
||||||
Theme: Catalog,
|
|
||||||
Renderer: advanced_text::Renderer,
|
|
||||||
Renderer::Font: 'a,
|
|
||||||
{
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
spans: Box::new([]),
|
|
||||||
size: None,
|
|
||||||
line_height: LineHeight::default(),
|
|
||||||
width: Length::Shrink,
|
|
||||||
height: Length::Shrink,
|
|
||||||
font: None,
|
|
||||||
align_x: Alignment::Default,
|
|
||||||
align_y: alignment::Vertical::Top,
|
|
||||||
wrapping: Wrapping::default(),
|
|
||||||
class: Theme::default(),
|
|
||||||
hovered_link: None,
|
|
||||||
on_link_click: None,
|
|
||||||
selection_color: Color::from_rgba(0.35, 0.55, 0.95, 0.35),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_spans(
|
|
||||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
spans: Box::new(spans),
|
|
||||||
..Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
|
||||||
self.size = Some(size.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
|
|
||||||
self.line_height = line_height.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
|
|
||||||
self.font = Some(font.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
|
||||||
self.width = width.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
|
||||||
self.height = height.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn align_x(mut self, alignment: impl Into<Alignment>) -> Self {
|
|
||||||
self.align_x = alignment.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn align_y(
|
|
||||||
mut self,
|
|
||||||
alignment: impl Into<alignment::Vertical>,
|
|
||||||
) -> Self {
|
|
||||||
self.align_y = alignment.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
|
|
||||||
self.wrapping = wrapping;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_link_click(
|
|
||||||
mut self,
|
|
||||||
on_link_click: impl Fn(Link) -> Message + 'a,
|
|
||||||
) -> Self {
|
|
||||||
self.on_link_click = Some(Box::new(on_link_click));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selection_color(mut self, color: Color) -> Self {
|
|
||||||
self.selection_color = Color {
|
|
||||||
a: color.a.min(0.35),
|
|
||||||
..color
|
|
||||||
};
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
|
|
||||||
where
|
|
||||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
|
||||||
{
|
|
||||||
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn color(self, color: impl Into<Color>) -> Self
|
|
||||||
where
|
|
||||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
|
||||||
{
|
|
||||||
self.color_maybe(Some(color))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn color_maybe(self, color: Option<impl Into<Color>>) -> Self
|
|
||||||
where
|
|
||||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
|
||||||
{
|
|
||||||
let color = color.map(Into::into);
|
|
||||||
|
|
||||||
self.style(move |_theme| Style { color })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
|
||||||
self.class = class.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, Link, Message, Theme, Renderer> Default
|
|
||||||
for SelectableRichText<'a, Link, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Link: Clone + 'static,
|
|
||||||
Theme: Catalog,
|
|
||||||
Renderer: advanced_text::Renderer,
|
|
||||||
Renderer::Font: 'a,
|
|
||||||
{
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SelectableTextState<Link, P: Paragraph> {
|
|
||||||
spans: Vec<Span<'static, Link, P::Font>>,
|
|
||||||
span_pressed: Option<usize>,
|
|
||||||
paragraph: P,
|
|
||||||
selection: Option<(usize, usize)>,
|
|
||||||
dragging: bool,
|
|
||||||
active: bool,
|
|
||||||
press_position: Option<Point>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Link, P: Paragraph> SelectableTextState<Link, P> {
|
|
||||||
fn selection_range(&self, text: &str) -> Option<(usize, usize)> {
|
|
||||||
let (anchor, cursor) = self.selection?;
|
|
||||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
|
||||||
|
|
||||||
(start != end).then_some((start, end))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
|
||||||
for SelectableRichText<'_, Link, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Link: Clone + 'static,
|
|
||||||
Theme: Catalog,
|
|
||||||
Renderer: advanced_text::Renderer,
|
|
||||||
{
|
|
||||||
fn tag(&self) -> tree::Tag {
|
|
||||||
tree::Tag::of::<SelectableTextState<Link, Renderer::Paragraph>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state(&self) -> tree::State {
|
|
||||||
tree::State::new(SelectableTextState::<Link, _> {
|
|
||||||
spans: Vec::new(),
|
|
||||||
span_pressed: None,
|
|
||||||
paragraph: Renderer::Paragraph::default(),
|
|
||||||
selection: None,
|
|
||||||
dragging: false,
|
|
||||||
active: false,
|
|
||||||
press_position: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn size(&self) -> Size<Length> {
|
|
||||||
Size {
|
|
||||||
width: self.width,
|
|
||||||
height: self.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(
|
|
||||||
&mut self,
|
|
||||||
tree: &mut Tree,
|
|
||||||
renderer: &Renderer,
|
|
||||||
limits: &layout::Limits,
|
|
||||||
) -> layout::Node {
|
|
||||||
layout_text(
|
|
||||||
tree.state
|
|
||||||
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>(),
|
|
||||||
renderer,
|
|
||||||
limits,
|
|
||||||
TextLayout {
|
|
||||||
width: self.width,
|
|
||||||
height: self.height,
|
|
||||||
spans: self.spans.as_ref().as_ref(),
|
|
||||||
line_height: self.line_height,
|
|
||||||
size: self.size,
|
|
||||||
font: self.font,
|
|
||||||
align_x: self.align_x,
|
|
||||||
align_y: self.align_y,
|
|
||||||
wrapping: self.wrapping,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(
|
|
||||||
&self,
|
|
||||||
tree: &Tree,
|
|
||||||
renderer: &mut Renderer,
|
|
||||||
theme: &Theme,
|
|
||||||
defaults: &renderer::Style,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
_cursor: mouse::Cursor,
|
|
||||||
viewport: &Rectangle,
|
|
||||||
) {
|
|
||||||
if !layout.bounds().intersects(viewport) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = tree
|
|
||||||
.state
|
|
||||||
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
|
|
||||||
let spans = self.spans.as_ref().as_ref();
|
|
||||||
let flat_text = flatten_spans(spans);
|
|
||||||
let style = theme.style(&self.class);
|
|
||||||
let translation = layout.position() - Point::ORIGIN;
|
|
||||||
|
|
||||||
if let Some((start, end)) = state.selection_range(&flat_text) {
|
|
||||||
let mut rects = selection_rects(&state.paragraph, spans.len(), start, end);
|
|
||||||
if rects.is_empty() {
|
|
||||||
rects = visual_lines(&state.paragraph, spans.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
for bounds in rects {
|
|
||||||
renderer.fill_quad(
|
|
||||||
renderer::Quad {
|
|
||||||
bounds: bounds + translation,
|
|
||||||
border: Border {
|
|
||||||
radius: 2.0.into(),
|
|
||||||
..Border::default()
|
|
||||||
},
|
|
||||||
..renderer::Quad::default()
|
|
||||||
},
|
|
||||||
Background::Color(self.selection_color),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (index, span) in spans.iter().enumerate() {
|
|
||||||
let is_hovered_link = self.on_link_click.is_some()
|
|
||||||
&& Some(index) == self.hovered_link;
|
|
||||||
|
|
||||||
if span.highlight.is_some()
|
|
||||||
|| span.underline
|
|
||||||
|| span.strikethrough
|
|
||||||
|| is_hovered_link
|
|
||||||
{
|
|
||||||
let regions = state.paragraph.span_bounds(index);
|
|
||||||
|
|
||||||
if let Some(highlight) = span.highlight {
|
|
||||||
for bounds in ®ions {
|
|
||||||
let bounds = Rectangle::new(
|
|
||||||
bounds.position()
|
|
||||||
- Vector::new(
|
|
||||||
span.padding.left,
|
|
||||||
span.padding.top,
|
|
||||||
),
|
|
||||||
bounds.size()
|
|
||||||
+ Size::new(span.padding.x(), span.padding.y()),
|
|
||||||
);
|
|
||||||
|
|
||||||
renderer.fill_quad(
|
|
||||||
renderer::Quad {
|
|
||||||
bounds: bounds + translation,
|
|
||||||
border: highlight.border,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
highlight.background,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if span.underline || span.strikethrough || is_hovered_link {
|
|
||||||
let size = span
|
|
||||||
.size
|
|
||||||
.or(self.size)
|
|
||||||
.unwrap_or(renderer.default_size());
|
|
||||||
|
|
||||||
let line_height = span
|
|
||||||
.line_height
|
|
||||||
.unwrap_or(self.line_height)
|
|
||||||
.to_absolute(size);
|
|
||||||
|
|
||||||
let color = span
|
|
||||||
.color
|
|
||||||
.or(style.color)
|
|
||||||
.unwrap_or(defaults.text_color);
|
|
||||||
|
|
||||||
let baseline = translation
|
|
||||||
+ Vector::new(
|
|
||||||
0.0,
|
|
||||||
size.0 + (line_height.0 - size.0) / 2.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if span.underline || is_hovered_link {
|
|
||||||
for bounds in ®ions {
|
|
||||||
renderer.fill_quad(
|
|
||||||
renderer::Quad {
|
|
||||||
bounds: Rectangle::new(
|
|
||||||
bounds.position() + baseline
|
|
||||||
- Vector::new(0.0, size.0 * 0.08),
|
|
||||||
Size::new(bounds.width, 1.0),
|
|
||||||
),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if span.strikethrough {
|
|
||||||
for bounds in ®ions {
|
|
||||||
renderer.fill_quad(
|
|
||||||
renderer::Quad {
|
|
||||||
bounds: Rectangle::new(
|
|
||||||
bounds.position() + baseline
|
|
||||||
- Vector::new(0.0, size.0 / 2.0),
|
|
||||||
Size::new(bounds.width, 1.0),
|
|
||||||
),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
widget_text::draw(
|
|
||||||
renderer,
|
|
||||||
defaults,
|
|
||||||
layout.bounds(),
|
|
||||||
&state.paragraph,
|
|
||||||
style,
|
|
||||||
viewport,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(
|
|
||||||
&mut self,
|
|
||||||
tree: &mut Tree,
|
|
||||||
event: &Event,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
clipboard: &mut dyn Clipboard,
|
|
||||||
shell: &mut Shell<'_, Message>,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
) {
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
let local_position = cursor.position_in(bounds);
|
|
||||||
let state = tree
|
|
||||||
.state
|
|
||||||
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>();
|
|
||||||
let spans = self.spans.as_ref().as_ref();
|
|
||||||
let flat_text = flatten_spans(spans);
|
|
||||||
|
|
||||||
let was_hovered = self.hovered_link.is_some();
|
|
||||||
self.hovered_link = local_position.and_then(|position| {
|
|
||||||
state.paragraph.hit_span(position).and_then(|span| {
|
|
||||||
if spans.get(span)?.link.is_some() {
|
|
||||||
Some(span)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if was_hovered != self.hovered_link.is_some() {
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
|
||||||
if let Some(position) = local_position {
|
|
||||||
state.active = true;
|
|
||||||
state.dragging = true;
|
|
||||||
state.press_position = Some(position);
|
|
||||||
state.span_pressed = self.hovered_link;
|
|
||||||
state.selection = state
|
|
||||||
.paragraph
|
|
||||||
.hit_test(position)
|
|
||||||
.map(|hit| {
|
|
||||||
let offset = hit.cursor().min(flat_text.len());
|
|
||||||
(offset, offset)
|
|
||||||
});
|
|
||||||
shell.capture_event();
|
|
||||||
shell.request_redraw();
|
|
||||||
} else if state.active || state.selection.is_some() {
|
|
||||||
state.active = false;
|
|
||||||
state.dragging = false;
|
|
||||||
state.press_position = None;
|
|
||||||
state.span_pressed = None;
|
|
||||||
state.selection = None;
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
|
||||||
if state.dragging
|
|
||||||
&& let Some(position) = clamped_position(cursor, bounds)
|
|
||||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
|
||||||
&& let Some((anchor, _)) = state.selection
|
|
||||||
{
|
|
||||||
state.selection =
|
|
||||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
||||||
if state.dragging {
|
|
||||||
let release_position = clamped_position(cursor, bounds)
|
|
||||||
.or(local_position)
|
|
||||||
.or(state.press_position);
|
|
||||||
let dragged = state
|
|
||||||
.press_position
|
|
||||||
.zip(release_position)
|
|
||||||
.is_some_and(|(start, end)| point_distance(start, end) > DRAG_THRESHOLD);
|
|
||||||
|
|
||||||
if let Some(position) = release_position
|
|
||||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
|
||||||
&& let Some((anchor, _)) = state.selection
|
|
||||||
{
|
|
||||||
state.selection =
|
|
||||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !dragged {
|
|
||||||
if let (Some(on_link_clicked), Some(span)) =
|
|
||||||
(&self.on_link_click, state.span_pressed)
|
|
||||||
&& Some(span) == self.hovered_link
|
|
||||||
&& let Some(link) =
|
|
||||||
spans.get(span).and_then(|span| span.link.clone())
|
|
||||||
{
|
|
||||||
shell.publish(on_link_clicked(link));
|
|
||||||
}
|
|
||||||
state.selection = None;
|
|
||||||
} else if state.selection_range(&flat_text).is_none() {
|
|
||||||
state.selection = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.dragging = false;
|
|
||||||
state.span_pressed = None;
|
|
||||||
state.press_position = None;
|
|
||||||
shell.capture_event();
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
|
||||||
key,
|
|
||||||
physical_key,
|
|
||||||
modifiers,
|
|
||||||
..
|
|
||||||
}) if state.active && modifiers.command() => {
|
|
||||||
match key.to_latin(*physical_key) {
|
|
||||||
Some('c') | Some('C') => {
|
|
||||||
if let Some((anchor, cursor)) = state.selection
|
|
||||||
&& let Some(selected) =
|
|
||||||
selected_substring(&flat_text, anchor, cursor)
|
|
||||||
{
|
|
||||||
clipboard.write(clipboard::Kind::Standard, selected);
|
|
||||||
shell.capture_event();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some('a') | Some('A') => {
|
|
||||||
state.selection = Some(select_all(&flat_text));
|
|
||||||
shell.capture_event();
|
|
||||||
shell.request_redraw();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mouse_interaction(
|
|
||||||
&self,
|
|
||||||
tree: &Tree,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
) -> mouse::Interaction {
|
|
||||||
let state = tree
|
|
||||||
.state
|
|
||||||
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
|
|
||||||
|
|
||||||
if state.dragging {
|
|
||||||
mouse::Interaction::Text
|
|
||||||
} else if self.hovered_link.is_some() {
|
|
||||||
mouse::Interaction::Pointer
|
|
||||||
} else if cursor.is_over(layout.bounds()) {
|
|
||||||
mouse::Interaction::Text
|
|
||||||
} else {
|
|
||||||
mouse::Interaction::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TextLayout<'a, 'span, Link, Font> {
|
|
||||||
width: Length,
|
|
||||||
height: Length,
|
|
||||||
spans: &'a [Span<'span, Link, Font>],
|
|
||||||
line_height: LineHeight,
|
|
||||||
size: Option<Pixels>,
|
|
||||||
font: Option<Font>,
|
|
||||||
align_x: Alignment,
|
|
||||||
align_y: alignment::Vertical,
|
|
||||||
wrapping: Wrapping,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout_text<Link, Renderer>(
|
|
||||||
state: &mut SelectableTextState<Link, Renderer::Paragraph>,
|
|
||||||
renderer: &Renderer,
|
|
||||||
limits: &layout::Limits,
|
|
||||||
config: TextLayout<'_, '_, Link, Renderer::Font>,
|
|
||||||
) -> layout::Node
|
|
||||||
where
|
|
||||||
Link: Clone,
|
|
||||||
Renderer: advanced_text::Renderer,
|
|
||||||
{
|
|
||||||
layout::sized(limits, config.width, config.height, |limits| {
|
|
||||||
let bounds = limits.max();
|
|
||||||
let size = config.size.unwrap_or_else(|| renderer.default_size());
|
|
||||||
let font = config.font.unwrap_or_else(|| renderer.default_font());
|
|
||||||
|
|
||||||
let text_with_spans = || advanced_text::Text {
|
|
||||||
content: config.spans,
|
|
||||||
bounds,
|
|
||||||
size,
|
|
||||||
line_height: config.line_height,
|
|
||||||
font,
|
|
||||||
align_x: config.align_x,
|
|
||||||
align_y: config.align_y,
|
|
||||||
shaping: Shaping::Advanced,
|
|
||||||
wrapping: config.wrapping,
|
|
||||||
};
|
|
||||||
|
|
||||||
if state.spans != config.spans {
|
|
||||||
state.paragraph =
|
|
||||||
Renderer::Paragraph::with_spans(text_with_spans());
|
|
||||||
state.spans = config
|
|
||||||
.spans
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(Span::to_static)
|
|
||||||
.collect();
|
|
||||||
} else {
|
|
||||||
match state.paragraph.compare(advanced_text::Text {
|
|
||||||
content: (),
|
|
||||||
bounds,
|
|
||||||
size,
|
|
||||||
line_height: config.line_height,
|
|
||||||
font,
|
|
||||||
align_x: config.align_x,
|
|
||||||
align_y: config.align_y,
|
|
||||||
shaping: Shaping::Advanced,
|
|
||||||
wrapping: config.wrapping,
|
|
||||||
}) {
|
|
||||||
advanced_text::Difference::None => {}
|
|
||||||
advanced_text::Difference::Bounds => {
|
|
||||||
state.paragraph.resize(bounds);
|
|
||||||
}
|
|
||||||
advanced_text::Difference::Shape => {
|
|
||||||
state.paragraph =
|
|
||||||
Renderer::Paragraph::with_spans(text_with_spans());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.paragraph.min_bounds()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flatten_spans<Link, Font>(spans: &[Span<'_, Link, Font>]) -> String {
|
|
||||||
spans
|
|
||||||
.iter()
|
|
||||||
.map(|span| span.text.as_ref())
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selection_rects<P: Paragraph>(
|
|
||||||
paragraph: &P,
|
|
||||||
span_count: usize,
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
) -> Vec<Rectangle> {
|
|
||||||
visual_lines(paragraph, span_count)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|line| selection_rect_for_line(paragraph, line, start, end))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selection_rect_for_line<P: Paragraph>(
|
|
||||||
paragraph: &P,
|
|
||||||
line: Rectangle,
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
) -> Option<Rectangle> {
|
|
||||||
let y = line.center_y();
|
|
||||||
let paragraph_width = paragraph.bounds().width.max(line.x + line.width + 1.0);
|
|
||||||
let left_probe = line.x.max(0.0);
|
|
||||||
let right_probe = (line.x + line.width + 1.0).min(paragraph_width.max(1.0));
|
|
||||||
let line_start = paragraph
|
|
||||||
.hit_test(Point::new(left_probe, y))
|
|
||||||
.map(advanced_text::Hit::cursor)?;
|
|
||||||
let line_end = paragraph
|
|
||||||
.hit_test(Point::new(right_probe, y))
|
|
||||||
.map(advanced_text::Hit::cursor)
|
|
||||||
.unwrap_or(line_start);
|
|
||||||
let (line_start, line_end) = if line_start <= line_end {
|
|
||||||
(line_start, line_end)
|
|
||||||
} else {
|
|
||||||
(line_end, line_start)
|
|
||||||
};
|
|
||||||
let overlap_start = start.max(line_start);
|
|
||||||
let overlap_end = end.min(line_end);
|
|
||||||
|
|
||||||
if overlap_start >= overlap_end {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let x_start = if overlap_start <= line_start {
|
|
||||||
line.x
|
|
||||||
} else {
|
|
||||||
x_for_offset(paragraph, y, overlap_start, line.x, line.x + line.width)
|
|
||||||
};
|
|
||||||
let x_end = if overlap_end >= line_end {
|
|
||||||
line.x + line.width
|
|
||||||
} else {
|
|
||||||
x_for_offset(paragraph, y, overlap_end, line.x, line.x + line.width)
|
|
||||||
};
|
|
||||||
let left = x_start.min(x_end);
|
|
||||||
let right = x_start.max(x_end);
|
|
||||||
|
|
||||||
(right > left).then(|| {
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(left, line.y),
|
|
||||||
Size::new(right - left, line.height),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn x_for_offset<P: Paragraph>(
|
|
||||||
paragraph: &P,
|
|
||||||
y: f32,
|
|
||||||
offset: usize,
|
|
||||||
left: f32,
|
|
||||||
right: f32,
|
|
||||||
) -> f32 {
|
|
||||||
let mut low = left;
|
|
||||||
let mut high = right.max(left);
|
|
||||||
|
|
||||||
for _ in 0..HIT_SEARCH_STEPS {
|
|
||||||
let mid = (low + high) / 2.0;
|
|
||||||
let hit = paragraph
|
|
||||||
.hit_test(Point::new(mid, y))
|
|
||||||
.map(advanced_text::Hit::cursor);
|
|
||||||
|
|
||||||
match hit {
|
|
||||||
Some(hit) if hit < offset => low = mid,
|
|
||||||
Some(_) => high = mid,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
high
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visual_lines<P: Paragraph>(
|
|
||||||
paragraph: &P,
|
|
||||||
span_count: usize,
|
|
||||||
) -> Vec<Rectangle> {
|
|
||||||
let mut lines: Vec<Rectangle> = Vec::new();
|
|
||||||
|
|
||||||
for span in 0..span_count {
|
|
||||||
for bounds in paragraph.span_bounds(span) {
|
|
||||||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(line) = lines
|
|
||||||
.iter_mut()
|
|
||||||
.find(|line| (line.center_y() - bounds.center_y()).abs() < 1.0)
|
|
||||||
{
|
|
||||||
*line = union(*line, bounds);
|
|
||||||
} else {
|
|
||||||
lines.push(bounds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.sort_by(|a, b| a.y.total_cmp(&b.y));
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
|
||||||
let left = a.x.min(b.x);
|
|
||||||
let top = a.y.min(b.y);
|
|
||||||
let right = (a.x + a.width).max(b.x + b.width);
|
|
||||||
let bottom = (a.y + a.height).max(b.y + b.height);
|
|
||||||
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(left, top),
|
|
||||||
Size::new(right - left, bottom - top),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
|
|
||||||
cursor.position_from(bounds.position()).map(|position| {
|
|
||||||
Point::new(
|
|
||||||
position.x.clamp(0.0, bounds.width.max(1.0) - 1.0),
|
|
||||||
position.y.clamp(0.0, bounds.height.max(1.0) - 1.0),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn point_distance(a: Point, b: Point) -> f32 {
|
|
||||||
let dx = a.x - b.x;
|
|
||||||
let dy = a.y - b.y;
|
|
||||||
|
|
||||||
(dx * dx + dy * dy).sqrt()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, Link, Message, Theme, Renderer>
|
|
||||||
From<SelectableRichText<'a, Link, Message, Theme, Renderer>>
|
|
||||||
for Element<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Message: 'a,
|
|
||||||
Link: Clone + 'a,
|
|
||||||
Theme: Catalog + 'a,
|
|
||||||
Renderer: advanced_text::Renderer + 'a,
|
|
||||||
{
|
|
||||||
fn from(
|
|
||||||
text: SelectableRichText<'a, Link, Message, Theme, Renderer>,
|
|
||||||
) -> Element<'a, Message, Theme, Renderer> {
|
|
||||||
Element::new(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selected_substring_returns_middle_and_none_for_collapsed() {
|
|
||||||
assert_eq!(selected_substring("abcdef", 2, 5), Some("cde".to_owned()));
|
|
||||||
assert_eq!(selected_substring("abcdef", 3, 3), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selected_substring_handles_reversed_range() {
|
|
||||||
assert_eq!(selected_substring("abcdef", 5, 2), Some("cde".to_owned()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn select_all_uses_byte_length() {
|
|
||||||
assert_eq!(select_all(""), (0, 0));
|
|
||||||
assert_eq!(select_all("aé👍z"), (0, "aé👍z".len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unicode_selection_uses_byte_offsets_without_panicking() {
|
|
||||||
let text = "aé👍z";
|
|
||||||
|
|
||||||
assert_eq!(selected_substring(text, 1, 7), Some("é👍".to_owned()));
|
|
||||||
assert_eq!(selected_substring(text, 3, 7), Some("👍".to_owned()));
|
|
||||||
assert_eq!(selected_substring(text, 2, 7), Some("é👍".to_owned()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user