Compare commits

...
7 Commits
Author SHA1 Message Date
mollusk 663956deaa Merge release 0.5.0: version bump + CHANGELOG
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-27 17:18:36 -04:00
molluskandClaude Opus 4.8 abc8531cfa chore(release): bump to 0.5.0 + add CHANGELOG
Version bump 0.4.0 -> 0.5.0 (Cargo.toml/lock, Windows .iss, PKGBUILD
placeholder) and a new CHANGELOG.md covering the 21 commits since v0.4.0
(A9 context menu, W21 selectable text, A25 clock-skew, A23 screen-share
per-app audio, A15 reliable critical commands, A24 per-peer volume,
Tier-C security caps, Debian/Arch packaging).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:18:36 -04:00
mollusk f0961a2049 Merge W21 Phase 2: drag-selectable chat messages 2026-06-27 16:44:26 -04:00
molluskandClaude Opus 4.8 32ee00178e W21 Phase 2: drag-selectable chat messages (per-message)
Add `SelectableRichText`, a custom iced widget that mirrors `rich_text`
(linkified spans + A13 link clicks) and adds per-message drag selection,
Ctrl/Cmd+C copy, and Ctrl/Cmd+A select-all. Swap it in for the chat body
element; the chat row and attachment layout are unchanged.

Selection offsets are paragraph-global byte offsets (matching cosmic-text's
hit_test), which equals a single global range because sanitize_chat keeps
every message on one logical line. Pure seam `selected_substring` /
`select_all` is unit-tested incl. unicode/emoji byte boundaries. Highlight
quads are computed from public Paragraph primitives, falling back to a
whole-message span_bounds union if sub-range rects can't be derived.

Only one message holds a selection at a time: each widget clears its own
selection on a left-press that lands outside its bounds. Right-click menu
(Part B) intentionally deferred — native Ctrl/Cmd+C/A is the path.

Implemented by Codex on branch, reviewed/committed by Claude.
464 lib tests, clippy --all-targets clean, release green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:44:22 -04:00
molluskandClaude Opus 4.8 7d9ffbd3a4 packaging: use anonymous HTTPS clone URL in PKGBUILD source
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The source fragment used git+ssh://git@gitbutter.xyz, which requires the
maintainer's SSH key — so no one else could build peerspeak-git. The repo
is public on gitbutter, so switch to the anonymous HTTPS clone URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:58:59 -04:00
mollusk 01150ff249 Merge W21 Phase 1: selectable node ID + ticket display fields
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-27 15:53:41 -04:00
molluskandClaude Opus 4.8 a6d9a8cbd4 W21 Phase 1: locked selectable display fields for node ID + ticket
Add a read-only-but-selectable "locked" mode to the A9 ContextInput so
share-critical values (full node ID, full room ticket) can be drag-selected
and copied with the mouse/keyboard, in addition to the existing one-click
Copy buttons (which are kept).

- context_input.rs: add `locked` flag + builder + `locked_value(value, noop)`
  constructor. A controlled text_input with a no-op on_input stays focusable
  and selection-capable while never mutating (iced treats on_input==None as
  Disabled, verified against iced_widget-0.14.2 source).
- Extract overlay gating into a pure `menu_action_enabled` seam: when locked,
  Cut/Paste are disabled, Copy is enabled with a (non-secure) selection, and
  Select All is enabled when there's a value. +1 unit test.
- app/mod.rs: add AppMessage::Noop; render the full node ID and full ticket in
  width-capped locked fields beside their existing Copy buttons.

Phase 2 (cross-message selectable chat transcript) intentionally deferred:
it requires a transcript-level custom widget that owns selection/layout/hit-
testing while preserving A13 links and attachment rows — out of scope for a
bounded edit. Design path recorded in the Codex task report.

Tests-green only (460 lib, clippy clean, release build green); wants a quick
field check of mouse drag-select + right-click Copy + Ctrl+A/C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:53:33 -04:00
9 changed files with 1004 additions and 19 deletions
+27
View File
@@ -0,0 +1,27 @@
# 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
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.4.0"
version = "0.5.0"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
# Application crate, not a crates.io library — refuse `cargo publish` and let
+2 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.4.0.r254.g913b0b6
pkgver=0.5.0.r0.g0000000
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
@@ -14,7 +14,7 @@ optdepends=('pixelpass: screen sharing inside a room'
provides=('peerspeak')
conflicts=('peerspeak')
options=('!lto' '!debug')
source=("$_pkgname::git+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
source=("$_pkgname::git+https://gitbutter.xyz/mollusk/peerspeak.git")
sha256sums=('SKIP')
pkgver() {
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.4.0"
#define MyAppVersion "0.5.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+25 -7
View File
@@ -11,12 +11,13 @@ use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode;
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::{
container, column, row, text, button, scrollable, slider, checkbox, pick_list,
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
rich_text, span, responsive,
span, responsive,
};
use iced::widget::text_input;
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
@@ -313,6 +314,8 @@ pub enum AppMessage {
CopyToClipboard,
/// Copy an arbitrary string to the clipboard (e.g. the full node ID).
CopyText(String),
/// No-op message for controlled read-only selectable fields.
Noop,
TogglePtt(bool),
StartHotkeyCapture(HotkeyAction),
ClearHotkey(HotkeyAction),
@@ -1522,6 +1525,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::CopyText(s) => {
return iced::clipboard::write(s);
}
AppMessage::Noop => {}
AppMessage::TogglePtt(enabled) => {
state.ptt_enabled = enabled;
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
@@ -3537,11 +3541,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
})
.into()
};
// The ID line shows a short form (iced text isn't selectable) plus a Copy
// button that puts the FULL node id on the clipboard, so it's shareable.
// The ID line exposes the full value in a locked selectable field while
// keeping the one-click Copy button for fast whole-ID copy.
let id_row: Element<AppMessage> = match state.self_node_id.clone() {
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(
row![
icon(IconKind::Copy, 13.0, color_text),
@@ -3550,7 +3558,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(5)
.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))
.padding(6),
]
@@ -4103,6 +4111,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text(format!("My ID: {}", short_id(&state.self_id)))
.size(14)
.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(
row![
icon(IconKind::Copy, 14.0, color_text),
@@ -4654,8 +4671,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
}
})
.collect();
let body = rich_text(spans)
let body = selectable_rich_text(spans)
.on_link_click(AppMessage::OpenUrl)
.selection_color(color_blue)
.width(iced::Length::Fill);
// Small avatar keyed on the sender's id (falls back to name); the
// " (You)" suffix on our own echoes is stripped for clean initials.
+56 -7
View File
@@ -105,6 +105,20 @@ where
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,
@@ -117,6 +131,7 @@ pub struct ContextInput<
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>>,
@@ -134,6 +149,7 @@ where
input: text_input::TextInput::new(placeholder, value),
value: value.to_owned(),
is_secure: false,
locked: false,
on_input: None,
on_paste: None,
style: None,
@@ -151,6 +167,11 @@ where
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,
@@ -448,6 +469,7 @@ where
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(),
@@ -479,6 +501,7 @@ where
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>>,
@@ -686,13 +709,13 @@ where
let has_selection = menu.selection.0 != menu.selection.1;
let has_value = !text_input::Value::new(self.value).is_empty();
match action {
MenuAction::Cut | MenuAction::Copy => {
has_selection && !self.is_secure
}
MenuAction::Paste => true,
MenuAction::SelectAll => has_value,
}
menu_action_enabled(
action,
has_selection,
has_value,
self.is_secure,
self.locked,
)
}
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)]
mod tests {
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
View File
@@ -1 +1,2 @@
pub mod context_input;
pub mod selectable_text;
+890
View File
@@ -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 &regions {
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 &regions {
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 &regions {
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()));
}
}