Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6054f0ecf9 | ||
|
|
c2e27c3367 | ||
|
|
3c678afaf7 | ||
|
|
8cfb5beff9 | ||
|
|
f21a027e78 | ||
|
|
663956deaa | ||
|
|
abc8531cfa | ||
|
|
f0961a2049 | ||
|
|
32ee00178e | ||
|
|
7d9ffbd3a4 | ||
|
|
01150ff249 | ||
|
|
a6d9a8cbd4 |
@@ -0,0 +1,33 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to PeerSpeak are documented here.
|
||||||
|
|
||||||
|
## [0.5.1] — 2026-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
|
||||||
|
|
||||||
|
## [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.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
|
||||||
|
[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.4.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.4.0"
|
version = "0.5.1"
|
||||||
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
|
||||||
|
|||||||
+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.4.0.r254.g913b0b6
|
pkgver=0.5.0.r0.g0000000
|
||||||
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+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
|
source=("$_pkgname::git+https://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.4.0"
|
#define MyAppVersion "0.5.0"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
+238
-15
@@ -11,12 +11,13 @@ 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;
|
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, 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,
|
||||||
rich_text, span, responsive,
|
span, responsive,
|
||||||
};
|
};
|
||||||
use iced::widget::text_input;
|
use iced::widget::text_input;
|
||||||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||||||
@@ -313,6 +314,8 @@ 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),
|
||||||
@@ -382,6 +385,10 @@ pub enum AppMessage {
|
|||||||
/// the full `(author, id)` key so the correct sender's bytes are fetched and
|
/// the full `(author, id)` key so the correct sender's bytes are fetched and
|
||||||
/// saved even if another peer reused the same attachment id (Tier C F-12).
|
/// saved even if another peer reused the same attachment id (Tier C F-12).
|
||||||
SaveAttachment(AttachmentKey),
|
SaveAttachment(AttachmentKey),
|
||||||
|
/// Open the click-to-enlarge image lightbox for this attachment (author + id).
|
||||||
|
OpenImageLightbox(AttachmentKey),
|
||||||
|
/// Close the image lightbox overlay.
|
||||||
|
CloseImageLightbox,
|
||||||
/// Result of the async save dialog: a status line to show, or None if cancelled.
|
/// Result of the async save dialog: a status line to show, or None if cancelled.
|
||||||
AttachmentSaved(Option<String>),
|
AttachmentSaved(Option<String>),
|
||||||
/// Fetch (if needed) and start an inline audio attachment. Carries the full
|
/// Fetch (if needed) and start an inline audio attachment. Carries the full
|
||||||
@@ -390,6 +397,15 @@ pub enum AppMessage {
|
|||||||
PauseAudio,
|
PauseAudio,
|
||||||
ResumeAudio,
|
ResumeAudio,
|
||||||
SeekAudio(crate::files::AttachmentId, f32),
|
SeekAudio(crate::files::AttachmentId, f32),
|
||||||
|
/// Adjust the universal inline-clip playback volume (`1.0` = unity) from the
|
||||||
|
/// master slider. Persisted to config; applied live only in universal mode.
|
||||||
|
SetClipVolume(f32),
|
||||||
|
/// Adjust volume from a clip's own row slider. In universal mode this drives
|
||||||
|
/// the shared level; otherwise it sets just that clip's in-memory level.
|
||||||
|
SetClipVolumeFor(crate::files::AttachmentId, f32),
|
||||||
|
/// Toggle whether one universal level governs every clip (checked) or each
|
||||||
|
/// clip keeps its own level (unchecked).
|
||||||
|
ToggleUniversalClipVolume(bool),
|
||||||
/// Redraw cadence while an inline clip is active.
|
/// Redraw cadence while an inline clip is active.
|
||||||
AudioTick,
|
AudioTick,
|
||||||
/// Send the current chat input line (Enter or the Send button).
|
/// Send the current chat input line (Enter or the Send button).
|
||||||
@@ -566,6 +582,9 @@ pub struct AppState {
|
|||||||
/// `(author, id)` and bounded. Session-only (cleared on leave); never
|
/// `(author, id)` and bounded. Session-only (cleared on leave); never
|
||||||
/// persisted. (Tier C F-02 bound + F-12 author keying.)
|
/// persisted. (Tier C F-02 bound + F-12 author keying.)
|
||||||
attachments: AttachmentCache,
|
attachments: AttachmentCache,
|
||||||
|
/// When `Some`, the image lightbox overlay is open showing this attachment
|
||||||
|
/// (click-to-enlarge from the chat panel). Closed = `None`.
|
||||||
|
image_lightbox: Option<AttachmentKey>,
|
||||||
/// Attachments the user asked to save before the bytes arrived; when the
|
/// Attachments the user asked to save before the bytes arrived; when the
|
||||||
/// fetch completes a save dialog is opened for them. Keyed by `(author, id)`
|
/// fetch completes a save dialog is opened for them. Keyed by `(author, id)`
|
||||||
/// so a same-id attachment from a different sender can't trigger the save.
|
/// so a same-id attachment from a different sender can't trigger the save.
|
||||||
@@ -579,6 +598,10 @@ pub struct AppState {
|
|||||||
/// the call capture/mixer path.
|
/// the call capture/mixer path.
|
||||||
clip_player: ClipPlayer,
|
clip_player: ClipPlayer,
|
||||||
clip_status: SharedClipStatus,
|
clip_status: SharedClipStatus,
|
||||||
|
/// Per-clip playback gain used when universal clip volume is disabled
|
||||||
|
/// (`config.clip_volume_universal == false`). In-memory only; absent clips
|
||||||
|
/// default to unity. Universal mode ignores this and uses `config.clip_volume`.
|
||||||
|
clip_volumes: HashMap<crate::files::AttachmentId, f32>,
|
||||||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||||
/// (The divider positions themselves are persisted in `config`.)
|
/// (The divider positions themselves are persisted in `config`.)
|
||||||
window_size: Size,
|
window_size: Size,
|
||||||
@@ -682,6 +705,7 @@ impl AppState {
|
|||||||
self.chat_messages.clear();
|
self.chat_messages.clear();
|
||||||
self.chat_input.clear();
|
self.chat_input.clear();
|
||||||
self.attachments.clear();
|
self.attachments.clear();
|
||||||
|
self.image_lightbox = None;
|
||||||
self.pending_saves.clear();
|
self.pending_saves.clear();
|
||||||
self.pending_plays.clear();
|
self.pending_plays.clear();
|
||||||
self.invalid_audio.clear();
|
self.invalid_audio.clear();
|
||||||
@@ -778,7 +802,7 @@ impl Default for AppState {
|
|||||||
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
||||||
|
|
||||||
let background_image = load_background_bytes(&config);
|
let background_image = load_background_bytes(&config);
|
||||||
let (clip_player, clip_status) = ClipPlayer::new();
|
let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||||
@@ -811,11 +835,13 @@ impl Default for AppState {
|
|||||||
recording_started: None,
|
recording_started: None,
|
||||||
chat_messages: Vec::new(),
|
chat_messages: Vec::new(),
|
||||||
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
|
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
|
||||||
|
image_lightbox: None,
|
||||||
pending_saves: HashSet::new(),
|
pending_saves: HashSet::new(),
|
||||||
pending_plays: HashSet::new(),
|
pending_plays: HashSet::new(),
|
||||||
invalid_audio: HashSet::new(),
|
invalid_audio: HashSet::new(),
|
||||||
clip_player,
|
clip_player,
|
||||||
clip_status,
|
clip_status,
|
||||||
|
clip_volumes: HashMap::new(),
|
||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
window_size: Size::new(ww, wh),
|
window_size: Size::new(ww, wh),
|
||||||
layout_picker_open: false,
|
layout_picker_open: false,
|
||||||
@@ -1522,6 +1548,7 @@ 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));
|
||||||
@@ -1758,6 +1785,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::CloseRegenerateIdentityConfirm => {
|
AppMessage::CloseRegenerateIdentityConfirm => {
|
||||||
state.regenerate_identity_confirm_open = false;
|
state.regenerate_identity_confirm_open = false;
|
||||||
}
|
}
|
||||||
|
AppMessage::OpenImageLightbox(key) => {
|
||||||
|
state.image_lightbox = Some(key);
|
||||||
|
}
|
||||||
|
AppMessage::CloseImageLightbox => {
|
||||||
|
state.image_lightbox = None;
|
||||||
|
}
|
||||||
AppMessage::ConfirmRegenerateIdentity => {
|
AppMessage::ConfirmRegenerateIdentity => {
|
||||||
state.regenerate_identity_confirm_open = false;
|
state.regenerate_identity_confirm_open = false;
|
||||||
// The core mints + persists the new key and replies with a fresh
|
// The core mints + persists the new key and replies with a fresh
|
||||||
@@ -2196,6 +2229,38 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.clip_player.seek(seek_target(fraction, total));
|
state.clip_player.seek(seek_target(fraction, total));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppMessage::SetClipVolume(volume) => {
|
||||||
|
// Master slider: always stores the universal level, but only the
|
||||||
|
// active player is nudged when universal mode is actually on.
|
||||||
|
let volume = volume.clamp(0.0, 2.0);
|
||||||
|
state.config.clip_volume = volume;
|
||||||
|
state.config.save();
|
||||||
|
if state.config.clip_volume_universal {
|
||||||
|
state.clip_player.set_volume(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::SetClipVolumeFor(id, volume) => {
|
||||||
|
let volume = volume.clamp(0.0, 2.0);
|
||||||
|
if state.config.clip_volume_universal {
|
||||||
|
state.config.clip_volume = volume;
|
||||||
|
state.config.save();
|
||||||
|
state.clip_player.set_volume(volume);
|
||||||
|
} else {
|
||||||
|
state.clip_volumes.insert(id, volume);
|
||||||
|
// Only the clip the user is dragging should react immediately.
|
||||||
|
if status_snapshot(&state.clip_status).playing_id == Some(id) {
|
||||||
|
state.clip_player.set_volume(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::ToggleUniversalClipVolume(on) => {
|
||||||
|
state.config.clip_volume_universal = on;
|
||||||
|
state.config.save();
|
||||||
|
// Reapply the now-effective level to whatever is currently playing.
|
||||||
|
if let Some(id) = status_snapshot(&state.clip_status).playing_id {
|
||||||
|
state.clip_player.set_volume(effective_clip_volume(state, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMessage::AudioTick => {
|
AppMessage::AudioTick => {
|
||||||
let clip = status_snapshot(&state.clip_status);
|
let clip = status_snapshot(&state.clip_status);
|
||||||
if let Some(failure) = clip.failure {
|
if let Some(failure) = clip.failure {
|
||||||
@@ -2246,7 +2311,13 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
.send(CoreCommand::SetMicMonitor { enabled, input_device });
|
.send(CoreCommand::SetMicMonitor { enabled, input_device });
|
||||||
}
|
}
|
||||||
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
|
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
|
||||||
if let Some(action) = state.hotkey_capture.take() {
|
if state.image_lightbox.is_some()
|
||||||
|
&& key == keyboard::Key::Named(keyboard::key::Named::Escape)
|
||||||
|
{
|
||||||
|
// Esc closes the image lightbox (takes priority over hotkeys so a
|
||||||
|
// user-bound Escape can't fire while the overlay is up).
|
||||||
|
state.image_lightbox = None;
|
||||||
|
} else if let Some(action) = state.hotkey_capture.take() {
|
||||||
if let Some(binding) = KeyBinding::from_key(&key) {
|
if let Some(binding) = KeyBinding::from_key(&key) {
|
||||||
state.config.hotkeys.set_binding(action, Some(binding));
|
state.config.hotkeys.set_binding(action, Some(binding));
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -2508,6 +2579,16 @@ fn find_attachment_source(
|
|||||||
|
|
||||||
/// Validate cached bytes and hand them to the independent clip player. A false
|
/// Validate cached bytes and hand them to the independent clip player. A false
|
||||||
/// filename hint falls back to the generic file chip without reaching rodio.
|
/// filename hint falls back to the generic file chip without reaching rodio.
|
||||||
|
/// Resolve the gain to use for clip `id`: the shared universal level, or the
|
||||||
|
/// clip's own stored level (defaulting to unity) when universal mode is off.
|
||||||
|
fn effective_clip_volume(state: &AppState, id: crate::files::AttachmentId) -> f32 {
|
||||||
|
if state.config.clip_volume_universal {
|
||||||
|
state.config.clip_volume
|
||||||
|
} else {
|
||||||
|
state.clip_volumes.get(&id).copied().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
|
fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
|
||||||
let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
|
let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
|
||||||
return;
|
return;
|
||||||
@@ -2517,6 +2598,9 @@ fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
|
|||||||
let bytes = data.clone();
|
let bytes = data.clone();
|
||||||
state.invalid_audio.remove(&id);
|
state.invalid_audio.remove(&id);
|
||||||
state.clip_player.play(id, bytes);
|
state.clip_player.play(id, bytes);
|
||||||
|
// Apply this clip's effective gain; the command lands after Play so it
|
||||||
|
// takes effect on the freshly connected player.
|
||||||
|
state.clip_player.set_volume(effective_clip_volume(state, id));
|
||||||
} else {
|
} else {
|
||||||
state.invalid_audio.insert(id);
|
state.invalid_audio.insert(id);
|
||||||
state.status_message = "This attachment is not valid supported audio.".to_string();
|
state.status_message = "This attachment is not valid supported audio.".to_string();
|
||||||
@@ -2988,9 +3072,10 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
/// through the gaps between panels. This is the registered top-level view.
|
/// through the gaps between panels. This is the registered top-level view.
|
||||||
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
||||||
let content = view(state);
|
let content = view(state);
|
||||||
let Some(bytes) = state.background_image.clone() else {
|
// Compose the base UI (optionally over a background image), then route the
|
||||||
return content;
|
// whole thing through the lightbox overlay so a click-to-enlarge image paints
|
||||||
};
|
// on top of everything (background + content).
|
||||||
|
let composed: Element<'_, AppMessage> = if let Some(bytes) = state.background_image.clone() {
|
||||||
let pal = state.config.theme.palette();
|
let pal = state.config.theme.palette();
|
||||||
let dim = state.config.background_dim;
|
let dim = state.config.background_dim;
|
||||||
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
||||||
@@ -3012,6 +3097,10 @@ fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.height(iced::Length::Fill)
|
.height(iced::Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
};
|
||||||
|
with_image_lightbox(composed, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||||
@@ -3537,11 +3626,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
// The ID line shows a short form (iced text isn't selectable) plus a Copy
|
// The ID line exposes the full value in a locked selectable field while
|
||||||
// button that puts the FULL node id on the clipboard, so it's shareable.
|
// keeping the one-click Copy button for fast whole-ID copy.
|
||||||
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(format!("ID: {id_display}")).size(13).color(color_text),
|
text("ID:").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),
|
||||||
@@ -3550,7 +3643,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))
|
.on_press(AppMessage::CopyText(full.clone()))
|
||||||
.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),
|
||||||
]
|
]
|
||||||
@@ -4103,6 +4196,15 @@ 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),
|
||||||
@@ -4654,8 +4756,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let body = rich_text(spans)
|
let body = selectable_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.
|
||||||
@@ -4704,9 +4807,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.into()
|
.into()
|
||||||
} else if att.kind == crate::files::AttachmentKind::Image {
|
} else if att.kind == crate::files::AttachmentKind::Image {
|
||||||
match key.as_ref().and_then(|k| state.attachments.handle(k)) {
|
match key.as_ref().and_then(|k| state.attachments.handle(k)) {
|
||||||
Some(handle) => iced::widget::image(handle.clone())
|
Some(handle) => {
|
||||||
.width(iced::Length::Fixed(260.0))
|
// Click an inline image to open it enlarged in the
|
||||||
|
// lightbox overlay (pointer cursor signals it's
|
||||||
|
// interactive). Only wired when we have a valid
|
||||||
|
// (author, id) key to look the handle back up.
|
||||||
|
let img = iced::widget::image(handle.clone())
|
||||||
|
.width(iced::Length::Fixed(260.0));
|
||||||
|
match key {
|
||||||
|
Some(k) => mouse_area(img)
|
||||||
|
.interaction(mouse::Interaction::Pointer)
|
||||||
|
.on_press(AppMessage::OpenImageLightbox(k))
|
||||||
.into(),
|
.into(),
|
||||||
|
None => img.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
None => text(format!("🖼 {} — loading…", att.name))
|
None => text(format!("🖼 {} — loading…", att.name))
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(color_subtext)
|
.color(color_subtext)
|
||||||
@@ -4787,6 +4902,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
text(format!("{elapsed} / {duration}"))
|
text(format!("{elapsed} / {duration}"))
|
||||||
.size(11)
|
.size(11)
|
||||||
.color(color_subtext),
|
.color(color_subtext),
|
||||||
|
// Per-clip volume. In universal mode this shows
|
||||||
|
// and drives the shared level; otherwise it is
|
||||||
|
// this clip's own remembered level.
|
||||||
|
text("🔊").size(12).color(color_subtext),
|
||||||
|
slider(
|
||||||
|
0.0..=2.0,
|
||||||
|
effective_clip_volume(state, att.id),
|
||||||
|
move |v| AppMessage::SetClipVolumeFor(att.id, v),
|
||||||
|
)
|
||||||
|
.step(0.01)
|
||||||
|
.width(iced::Length::Fixed(80.0)),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
@@ -4850,8 +4976,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::alignment::Vertical::Center);
|
.align_y(iced::alignment::Vertical::Center);
|
||||||
let chat_inner = column![
|
// Chat header: title on the left, the universal-volume control on the
|
||||||
|
// right. The master slider drives every clip when "Universal" is checked;
|
||||||
|
// when unchecked each clip keeps its own level and this slider is inert.
|
||||||
|
let universal = state.config.clip_volume_universal;
|
||||||
|
let chat_header = row![
|
||||||
text("Chat").size(16).color(color_blue),
|
text("Chat").size(16).color(color_blue),
|
||||||
|
horizontal_space(),
|
||||||
|
checkbox(universal)
|
||||||
|
.label("Universal volume")
|
||||||
|
.text_size(12)
|
||||||
|
.on_toggle(AppMessage::ToggleUniversalClipVolume),
|
||||||
|
text("🔊").size(13).color(color_subtext),
|
||||||
|
slider(0.0..=2.0, state.config.clip_volume, AppMessage::SetClipVolume)
|
||||||
|
.step(0.01)
|
||||||
|
.width(iced::Length::Fixed(110.0)),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(iced::alignment::Vertical::Center);
|
||||||
|
let chat_inner = column![
|
||||||
|
chat_header,
|
||||||
chat_scroll,
|
chat_scroll,
|
||||||
chat_input_row,
|
chat_input_row,
|
||||||
]
|
]
|
||||||
@@ -5824,6 +5968,85 @@ fn with_regenerate_confirm<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Overlay the click-to-enlarge image lightbox when `state.image_lightbox` is set
|
||||||
|
/// (W22). Clicking the dimmed backdrop, the enlarged image, or the top-right ✕
|
||||||
|
/// button closes it (`CloseImageLightbox`). Modeled on `with_regenerate_confirm`.
|
||||||
|
fn with_image_lightbox<'a>(
|
||||||
|
base: Element<'a, AppMessage>,
|
||||||
|
state: &'a AppState,
|
||||||
|
) -> Element<'a, AppMessage> {
|
||||||
|
let Some(key) = state.image_lightbox.as_ref() else {
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
// If the cached handle was evicted (Tier C F-02 bound), there's nothing to
|
||||||
|
// show — fall back to the base UI rather than an empty overlay.
|
||||||
|
let Some(handle) = state.attachments.handle(key) else {
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
let pal = state.config.theme.palette();
|
||||||
|
let crust = pal.crust;
|
||||||
|
let mantle = pal.mantle;
|
||||||
|
let surface = pal.surface;
|
||||||
|
let text_c = pal.text;
|
||||||
|
|
||||||
|
// Full-window dim that closes on click (click-outside-to-close).
|
||||||
|
let backdrop = mouse_area(
|
||||||
|
container(horizontal_space())
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill)
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color { a: 0.80, ..crust })),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_press(AppMessage::CloseImageLightbox);
|
||||||
|
|
||||||
|
// Contain-fit so the image scales down to the window without cropping or
|
||||||
|
// overflowing, bounded to most of the viewport by the surrounding padding.
|
||||||
|
let big = iced::widget::image(handle.clone())
|
||||||
|
.content_fit(iced::ContentFit::Contain)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill);
|
||||||
|
|
||||||
|
// ✕ close button, pinned top-right (subtle styling matching the dialog's
|
||||||
|
// Cancel button).
|
||||||
|
let close_btn = button(text("✕").size(18))
|
||||||
|
.on_press(AppMessage::CloseImageLightbox)
|
||||||
|
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||||||
|
background: Some(Background::Color(match status {
|
||||||
|
button::Status::Hovered => surface,
|
||||||
|
_ => mantle,
|
||||||
|
})),
|
||||||
|
text_color: text_c,
|
||||||
|
border: Border { color: surface, width: 1.0, radius: 6.0.into() },
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding(8);
|
||||||
|
|
||||||
|
// The image sits in its own layer above the backdrop, so clicking the picture
|
||||||
|
// wouldn't reach the backdrop — wrap it so the image itself also closes.
|
||||||
|
let image_area = mouse_area(
|
||||||
|
container(big)
|
||||||
|
.center_x(iced::Length::Fill)
|
||||||
|
.center_y(iced::Length::Fill)
|
||||||
|
.padding(40),
|
||||||
|
)
|
||||||
|
.on_press(AppMessage::CloseImageLightbox);
|
||||||
|
|
||||||
|
stack![
|
||||||
|
base,
|
||||||
|
backdrop,
|
||||||
|
image_area,
|
||||||
|
container(close_btn)
|
||||||
|
.align_x(iced::alignment::Horizontal::Right)
|
||||||
|
.align_y(iced::alignment::Vertical::Top)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill)
|
||||||
|
.padding(16),
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
/// A small two-pane glyph for the square layout-picker button in the top bar.
|
/// A small two-pane glyph for the square layout-picker button in the top bar.
|
||||||
struct LayoutIcon {
|
struct LayoutIcon {
|
||||||
fg: Color,
|
fg: Color,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ enum ClipCommand {
|
|||||||
Resume,
|
Resume,
|
||||||
Seek(Duration),
|
Seek(Duration),
|
||||||
Stop,
|
Stop,
|
||||||
|
SetVolume(f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cheap, `Send` command handle for the dedicated playback thread.
|
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||||
@@ -51,13 +52,16 @@ pub struct ClipPlayer {
|
|||||||
impl ClipPlayer {
|
impl ClipPlayer {
|
||||||
/// Start the playback worker. The system output device is opened lazily on
|
/// Start the playback worker. The system output device is opened lazily on
|
||||||
/// first Play, so merely launching PeerSpeak never claims another stream.
|
/// first Play, so merely launching PeerSpeak never claims another stream.
|
||||||
pub fn new() -> (Self, SharedClipStatus) {
|
///
|
||||||
|
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
|
||||||
|
/// clip, restored from config so the level persists across sessions.
|
||||||
|
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
|
||||||
let (command_tx, command_rx) = mpsc::channel();
|
let (command_tx, command_rx) = mpsc::channel();
|
||||||
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||||
let worker_status = Arc::clone(&status);
|
let worker_status = Arc::clone(&status);
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("peerspeak-clip-player".to_string())
|
.name("peerspeak-clip-player".to_string())
|
||||||
.spawn(move || playback_worker(command_rx, worker_status))
|
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
|
||||||
.expect("failed to spawn clip playback thread");
|
.expect("failed to spawn clip playback thread");
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
@@ -94,11 +98,24 @@ impl ClipPlayer {
|
|||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
let _ = self.command_tx.send(ClipCommand::Stop);
|
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the universal playback gain (`1.0` = unity). Applies to the current
|
||||||
|
/// clip immediately and to every clip played afterwards.
|
||||||
|
pub fn set_volume(&self, volume: f32) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
fn playback_worker(
|
||||||
|
command_rx: mpsc::Receiver<ClipCommand>,
|
||||||
|
status: SharedClipStatus,
|
||||||
|
initial_volume: f32,
|
||||||
|
) {
|
||||||
let mut output: Option<MixerDeviceSink> = None;
|
let mut output: Option<MixerDeviceSink> = None;
|
||||||
let mut player: Option<Player> = None;
|
let mut player: Option<Player> = None;
|
||||||
|
// Universal gain remembered across clips so a level set on one upload
|
||||||
|
// carries to the next; reapplied to each freshly connected player.
|
||||||
|
let mut volume = initial_volume.max(0.0);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
@@ -124,7 +141,9 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
|
|||||||
if output.is_none() {
|
if output.is_none() {
|
||||||
match DeviceSinkBuilder::open_default_sink() {
|
match DeviceSinkBuilder::open_default_sink() {
|
||||||
Ok(sink) => {
|
Ok(sink) => {
|
||||||
player = Some(Player::connect_new(sink.mixer()));
|
let new_player = Player::connect_new(sink.mixer());
|
||||||
|
new_player.set_volume(volume);
|
||||||
|
player = Some(new_player);
|
||||||
output = Some(sink);
|
output = Some(sink);
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -177,6 +196,12 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
|
|||||||
}
|
}
|
||||||
reset(&status);
|
reset(&status);
|
||||||
}
|
}
|
||||||
|
Ok(ClipCommand::SetVolume(level)) => {
|
||||||
|
volume = level.max(0.0);
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.set_volume(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ pub struct AppConfig {
|
|||||||
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
||||||
#[serde(default = "default_volume")]
|
#[serde(default = "default_volume")]
|
||||||
pub output_volume: f32,
|
pub output_volume: f32,
|
||||||
|
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
|
||||||
|
/// level shared by every uploaded clip so the slider sticks across plays.
|
||||||
|
#[serde(default = "default_volume")]
|
||||||
|
pub clip_volume: f32,
|
||||||
|
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||||
|
/// its own (in-memory) level and the universal slider is inactive.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub clip_volume_universal: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub network_mode: NetworkMode,
|
pub network_mode: NetworkMode,
|
||||||
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||||
@@ -312,6 +320,8 @@ impl Default for AppConfig {
|
|||||||
noise_gate_threshold: 0.01,
|
noise_gate_threshold: 0.01,
|
||||||
input_volume: 1.0,
|
input_volume: 1.0,
|
||||||
output_volume: 1.0,
|
output_volume: 1.0,
|
||||||
|
clip_volume: 1.0,
|
||||||
|
clip_volume_universal: true,
|
||||||
network_mode: NetworkMode::default(),
|
network_mode: NetworkMode::default(),
|
||||||
presence_mode: crate::presence::PresenceMode::default(),
|
presence_mode: crate::presence::PresenceMode::default(),
|
||||||
echo_cancellation_enabled: false,
|
echo_cancellation_enabled: false,
|
||||||
@@ -657,23 +667,32 @@ mod tests {
|
|||||||
let def = AppConfig::default();
|
let def = AppConfig::default();
|
||||||
assert_eq!(def.input_volume, 1.0);
|
assert_eq!(def.input_volume, 1.0);
|
||||||
assert_eq!(def.output_volume, 1.0);
|
assert_eq!(def.output_volume, 1.0);
|
||||||
|
assert_eq!(def.clip_volume, 1.0);
|
||||||
|
assert!(def.clip_volume_universal);
|
||||||
|
|
||||||
// Missing in JSON → unity (serde default).
|
// Missing in JSON → unity (serde default).
|
||||||
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||||
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
||||||
assert_eq!(cfg_missing.input_volume, 1.0);
|
assert_eq!(cfg_missing.input_volume, 1.0);
|
||||||
assert_eq!(cfg_missing.output_volume, 1.0);
|
assert_eq!(cfg_missing.output_volume, 1.0);
|
||||||
|
assert_eq!(cfg_missing.clip_volume, 1.0);
|
||||||
|
// Configs predating the toggle default to universal mode.
|
||||||
|
assert!(cfg_missing.clip_volume_universal);
|
||||||
|
|
||||||
// Explicit non-unity values are preserved across a round-trip.
|
// Explicit non-unity values are preserved across a round-trip.
|
||||||
let cfg = AppConfig {
|
let cfg = AppConfig {
|
||||||
input_volume: 1.5,
|
input_volume: 1.5,
|
||||||
output_volume: 0.25,
|
output_volume: 0.25,
|
||||||
|
clip_volume: 0.7,
|
||||||
|
clip_volume_universal: false,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let round_tripped: AppConfig =
|
let round_tripped: AppConfig =
|
||||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||||
assert_eq!(round_tripped.input_volume, 1.5);
|
assert_eq!(round_tripped.input_volume, 1.5);
|
||||||
assert_eq!(round_tripped.output_volume, 0.25);
|
assert_eq!(round_tripped.output_volume, 0.25);
|
||||||
|
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||||
|
assert!(!round_tripped.clip_volume_universal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -105,6 +105,20 @@ where
|
|||||||
ContextInput::new(placeholder, value)
|
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<
|
pub struct ContextInput<
|
||||||
'a,
|
'a,
|
||||||
Message,
|
Message,
|
||||||
@@ -117,6 +131,7 @@ pub struct ContextInput<
|
|||||||
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
||||||
value: String,
|
value: String,
|
||||||
is_secure: bool,
|
is_secure: bool,
|
||||||
|
locked: bool,
|
||||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
style: Option<InputStyleFn<'a, Theme>>,
|
style: Option<InputStyleFn<'a, Theme>>,
|
||||||
@@ -134,6 +149,7 @@ where
|
|||||||
input: text_input::TextInput::new(placeholder, value),
|
input: text_input::TextInput::new(placeholder, value),
|
||||||
value: value.to_owned(),
|
value: value.to_owned(),
|
||||||
is_secure: false,
|
is_secure: false,
|
||||||
|
locked: false,
|
||||||
on_input: None,
|
on_input: None,
|
||||||
on_paste: None,
|
on_paste: None,
|
||||||
style: None,
|
style: None,
|
||||||
@@ -151,6 +167,11 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn locked(mut self, yes: bool) -> Self {
|
||||||
|
self.locked = yes;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_input(
|
pub fn on_input(
|
||||||
mut self,
|
mut self,
|
||||||
on_input: impl Fn(String) -> Message + 'a,
|
on_input: impl Fn(String) -> Message + 'a,
|
||||||
@@ -448,6 +469,7 @@ where
|
|||||||
input_state,
|
input_state,
|
||||||
value: &self.value,
|
value: &self.value,
|
||||||
is_secure: self.is_secure,
|
is_secure: self.is_secure,
|
||||||
|
locked: self.locked,
|
||||||
on_input: self.on_input.clone(),
|
on_input: self.on_input.clone(),
|
||||||
on_paste: self.on_paste.clone(),
|
on_paste: self.on_paste.clone(),
|
||||||
style: self.style.clone(),
|
style: self.style.clone(),
|
||||||
@@ -479,6 +501,7 @@ where
|
|||||||
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
||||||
value: &'a str,
|
value: &'a str,
|
||||||
is_secure: bool,
|
is_secure: bool,
|
||||||
|
locked: bool,
|
||||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
style: Option<InputStyleFn<'a, Theme>>,
|
style: Option<InputStyleFn<'a, Theme>>,
|
||||||
@@ -686,13 +709,13 @@ where
|
|||||||
let has_selection = menu.selection.0 != menu.selection.1;
|
let has_selection = menu.selection.0 != menu.selection.1;
|
||||||
let has_value = !text_input::Value::new(self.value).is_empty();
|
let has_value = !text_input::Value::new(self.value).is_empty();
|
||||||
|
|
||||||
match action {
|
menu_action_enabled(
|
||||||
MenuAction::Cut | MenuAction::Copy => {
|
action,
|
||||||
has_selection && !self.is_secure
|
has_selection,
|
||||||
}
|
has_value,
|
||||||
MenuAction::Paste => true,
|
self.is_secure,
|
||||||
MenuAction::SelectAll => has_value,
|
self.locked,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hit_action(
|
fn hit_action(
|
||||||
@@ -811,6 +834,21 @@ fn disabled_color(color: Color) -> 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -891,4 +929,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 +1,2 @@
|
|||||||
pub mod context_input;
|
pub mod context_input;
|
||||||
|
pub mod selectable_text;
|
||||||
|
|||||||
@@ -0,0 +1,890 @@
|
|||||||
|
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