[cosmic] adds cosmic implementation (codex)
This commit is contained in:
2
cosmic/.gitignore
vendored
Normal file
2
cosmic/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
/screenshots/
|
||||
6879
cosmic/Cargo.lock
generated
Normal file
6879
cosmic/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
cosmic/Cargo.toml
Normal file
29
cosmic/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "kordophone-cosmic"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.93"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
kordophoned-client = { path = "../core/kordophoned-client" }
|
||||
open = "5"
|
||||
regex = "1"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
|
||||
[dependencies.libcosmic]
|
||||
path = "/home/buzzert/src/cosmic/libcosmic"
|
||||
default-features = false
|
||||
features = [
|
||||
"advanced-shaping",
|
||||
"multi-window",
|
||||
"tokio",
|
||||
"winit",
|
||||
"wgpu",
|
||||
"xdg-portal",
|
||||
]
|
||||
1041
cosmic/src/app.rs
Normal file
1041
cosmic/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
21
cosmic/src/main.rs
Normal file
21
cosmic/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
mod app;
|
||||
mod transcript;
|
||||
|
||||
use app::{App, Flags};
|
||||
use cosmic::app::Settings;
|
||||
use cosmic::iced::Size;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_timer(tracing_subscriber::fmt::time::uptime())
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "warn,kordophone_cosmic=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let flags = Flags::from_env();
|
||||
let settings = Settings::default().size(Size::new(1120.0, 780.0));
|
||||
cosmic::app::run::<App>(settings, flags)?;
|
||||
Ok(())
|
||||
}
|
||||
633
cosmic/src/transcript.rs
Normal file
633
cosmic/src/transcript.rs
Normal file
@@ -0,0 +1,633 @@
|
||||
use chrono::{Local, TimeZone};
|
||||
use cosmic::iced::advanced::image::{self, Renderer as ImageRenderer};
|
||||
use cosmic::iced::advanced::layout::{self, Layout};
|
||||
use cosmic::iced::advanced::renderer;
|
||||
use cosmic::iced::advanced::text;
|
||||
use cosmic::iced::advanced::text::{Paragraph as _, Renderer as _};
|
||||
use cosmic::iced::advanced::widget::{self, Widget};
|
||||
use cosmic::iced::advanced::{Clipboard, Renderer as _, Shell};
|
||||
use cosmic::iced::border;
|
||||
use cosmic::iced::mouse;
|
||||
use cosmic::iced::{
|
||||
Background, Color, Event, Length, Pixels, Point, Rectangle, Size, Vector, alignment,
|
||||
};
|
||||
use cosmic::{Element, Renderer, Theme};
|
||||
use regex::Regex;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::ops::Range;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
use tracing::Level;
|
||||
|
||||
const BUBBLE_MARGIN: f32 = 24.0;
|
||||
const MAX_BUBBLE_WIDTH: f32 = 0.74;
|
||||
const IMAGE_WIDTH_FACTOR: f32 = 0.70;
|
||||
const RADIUS: f32 = 16.0;
|
||||
const TEXT_X_PADDING: f32 = 14.0;
|
||||
const TEXT_Y_PADDING: f32 = 8.0;
|
||||
const DATE_PADDING: f32 = 36.0;
|
||||
const SENDER_PADDING: f32 = 6.0;
|
||||
const TRANSCRIPT_TOP_PADDING: f32 = 10.0;
|
||||
|
||||
type Paragraph = <Renderer as text::Renderer>::Paragraph;
|
||||
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
pub struct TranscriptMessage {
|
||||
pub id: String,
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub date_unix: i64,
|
||||
pub from_me: bool,
|
||||
pub should_animate: bool,
|
||||
pub attachments: Vec<TranscriptAttachment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
pub struct TranscriptAttachment {
|
||||
pub guid: String,
|
||||
pub preview_path: Option<String>,
|
||||
pub downloaded: bool,
|
||||
pub preview_downloaded: bool,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TranscriptAction {
|
||||
CopyText(String),
|
||||
OpenUrl(String),
|
||||
OpenAttachment(String),
|
||||
}
|
||||
|
||||
pub struct Transcript<'a, Message> {
|
||||
messages: Vec<TranscriptMessage>,
|
||||
show_sender: bool,
|
||||
fingerprint: u64,
|
||||
on_action: Box<dyn Fn(TranscriptAction) -> Message + 'a>,
|
||||
}
|
||||
|
||||
impl<'a, Message> Transcript<'a, Message> {
|
||||
pub fn new(
|
||||
messages: Vec<TranscriptMessage>,
|
||||
show_sender: bool,
|
||||
on_action: impl Fn(TranscriptAction) -> Message + 'a,
|
||||
) -> Self {
|
||||
let fingerprint = messages_fingerprint(&messages);
|
||||
Self {
|
||||
messages,
|
||||
show_sender,
|
||||
fingerprint,
|
||||
on_action: Box::new(on_action),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn messages_fingerprint(messages: &[TranscriptMessage]) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
messages.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum DisplayKind {
|
||||
Date {
|
||||
paragraph: Paragraph,
|
||||
},
|
||||
Sender {
|
||||
paragraph: Paragraph,
|
||||
},
|
||||
Text {
|
||||
paragraph: Paragraph,
|
||||
text: String,
|
||||
from_me: bool,
|
||||
},
|
||||
Image {
|
||||
attachment_guid: String,
|
||||
preview_handle: Option<image::Handle>,
|
||||
from_me: bool,
|
||||
downloaded: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DisplayItem {
|
||||
bounds: Rectangle,
|
||||
content_bounds: Rectangle,
|
||||
kind: DisplayKind,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DisplayList {
|
||||
items: Vec<DisplayItem>,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct DisplayListKey {
|
||||
width_px: u32,
|
||||
show_sender: bool,
|
||||
fingerprint: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TranscriptState {
|
||||
cache_key: Option<DisplayListKey>,
|
||||
display_list: DisplayList,
|
||||
}
|
||||
|
||||
impl DisplayList {
|
||||
fn hit(&self, point: Point) -> Option<&DisplayItem> {
|
||||
let start = self
|
||||
.items
|
||||
.partition_point(|item| item.bounds.y + item.bounds.height < point.y);
|
||||
|
||||
self.items[start..]
|
||||
.iter()
|
||||
.take_while(|item| item.bounds.y <= point.y)
|
||||
.find(|item| item.bounds.contains(point))
|
||||
}
|
||||
|
||||
fn visible_range(&self, viewport: Rectangle) -> Range<usize> {
|
||||
let top = viewport.y;
|
||||
let bottom = viewport.y + viewport.height;
|
||||
let start = self
|
||||
.items
|
||||
.partition_point(|item| item.bounds.y + item.bounds.height < top);
|
||||
let end = start
|
||||
+ self.items[start..]
|
||||
.iter()
|
||||
.take_while(|item| item.bounds.y <= bottom)
|
||||
.count();
|
||||
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
fn url_regex() -> &'static Regex {
|
||||
static URL_RE: OnceLock<Regex> = OnceLock::new();
|
||||
URL_RE.get_or_init(|| {
|
||||
Regex::new(r"https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&//=]*")
|
||||
.expect("valid URL regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn first_url(text: &str) -> Option<String> {
|
||||
url_regex().find(text).map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
fn is_attachment_marker(text: &str) -> bool {
|
||||
text == "\u{FFFC}"
|
||||
}
|
||||
|
||||
fn date_label(timestamp: i64) -> String {
|
||||
Local
|
||||
.timestamp_opt(timestamp, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%b %d, %Y at %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown date".to_string())
|
||||
}
|
||||
|
||||
fn paragraph(
|
||||
renderer: &Renderer,
|
||||
content: &str,
|
||||
width: f32,
|
||||
size: f32,
|
||||
line_height: f32,
|
||||
align_x: text::Alignment,
|
||||
) -> Paragraph {
|
||||
Paragraph::with_text(text::Text {
|
||||
content,
|
||||
bounds: Size::new(width, f32::INFINITY),
|
||||
size: Pixels(size),
|
||||
line_height: text::LineHeight::Relative(line_height),
|
||||
font: renderer.default_font(),
|
||||
align_x,
|
||||
align_y: alignment::Vertical::Top,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::WordOrGlyph,
|
||||
ellipsize: text::Ellipsize::None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_display_list(
|
||||
messages: &[TranscriptMessage],
|
||||
show_sender: bool,
|
||||
width: f32,
|
||||
renderer: &Renderer,
|
||||
) -> DisplayList {
|
||||
let max_width = (width * MAX_BUBBLE_WIDTH).max(220.0);
|
||||
let max_image_width = max_width * IMAGE_WIDTH_FACTOR;
|
||||
let mut items = Vec::new();
|
||||
let mut y = TRANSCRIPT_TOP_PADDING;
|
||||
let mut last_date: Option<i64> = None;
|
||||
let mut last_sender: Option<&str> = None;
|
||||
|
||||
for message in messages {
|
||||
if last_date.is_some_and(|date| message.date_unix - date > 60 * 60) {
|
||||
let text = date_label(message.date_unix);
|
||||
let paragraph = paragraph(
|
||||
renderer,
|
||||
&text,
|
||||
max_width,
|
||||
12.0,
|
||||
1.2,
|
||||
text::Alignment::Center,
|
||||
);
|
||||
let height = paragraph.min_height() + DATE_PADDING;
|
||||
let x = ((width - max_width) / 2.0).max(BUBBLE_MARGIN);
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds: bounds,
|
||||
kind: DisplayKind::Date { paragraph },
|
||||
});
|
||||
y += height;
|
||||
last_sender = None;
|
||||
}
|
||||
|
||||
if show_sender && !message.from_me && last_sender != Some(message.sender.as_str()) {
|
||||
let paragraph = paragraph(
|
||||
renderer,
|
||||
&message.sender,
|
||||
max_width,
|
||||
12.0,
|
||||
1.2,
|
||||
text::Alignment::Left,
|
||||
);
|
||||
let height = paragraph.min_height() + SENDER_PADDING;
|
||||
let x = BUBBLE_MARGIN;
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds: bounds,
|
||||
kind: DisplayKind::Sender { paragraph },
|
||||
});
|
||||
y += height;
|
||||
}
|
||||
|
||||
if !message.text.is_empty() && !is_attachment_marker(&message.text) {
|
||||
let text_available_width = max_width - TEXT_X_PADDING * 2.0;
|
||||
let paragraph = paragraph(
|
||||
renderer,
|
||||
&message.text,
|
||||
text_available_width,
|
||||
14.0,
|
||||
1.18,
|
||||
text::Alignment::Left,
|
||||
);
|
||||
let text_bounds = paragraph.min_bounds();
|
||||
let bubble_width = (text_bounds.width + TEXT_X_PADDING * 2.0).min(max_width);
|
||||
let bubble_height = text_bounds.height + TEXT_Y_PADDING * 2.0;
|
||||
let x = if message.from_me {
|
||||
width - bubble_width - BUBBLE_MARGIN
|
||||
} else {
|
||||
BUBBLE_MARGIN
|
||||
};
|
||||
let vertical_padding = if last_sender == Some(message.sender.as_str()) {
|
||||
4.0
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(bubble_width, bubble_height));
|
||||
let content_bounds = Rectangle::new(
|
||||
Point::new(x + TEXT_X_PADDING, y + TEXT_Y_PADDING),
|
||||
Size::new(text_available_width, text_bounds.height),
|
||||
);
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds,
|
||||
kind: DisplayKind::Text {
|
||||
paragraph,
|
||||
text: message.text.clone(),
|
||||
from_me: message.from_me,
|
||||
},
|
||||
});
|
||||
y += bubble_height + vertical_padding;
|
||||
}
|
||||
|
||||
for attachment in &message.attachments {
|
||||
let intrinsic_width = attachment.width.unwrap_or(200) as f32;
|
||||
let intrinsic_height = attachment.height.unwrap_or(150) as f32;
|
||||
let scale = (max_image_width / intrinsic_width).min(1.0);
|
||||
let image_width = (intrinsic_width * scale).max(200.0).min(max_image_width);
|
||||
let image_height = (intrinsic_height * scale).max(100.0);
|
||||
let x = if message.from_me {
|
||||
width - image_width - BUBBLE_MARGIN
|
||||
} else {
|
||||
BUBBLE_MARGIN
|
||||
};
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(image_width, image_height));
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds: bounds,
|
||||
kind: DisplayKind::Image {
|
||||
attachment_guid: attachment.guid.clone(),
|
||||
preview_handle: attachment
|
||||
.preview_path
|
||||
.as_ref()
|
||||
.map(image::Handle::from_path),
|
||||
from_me: message.from_me,
|
||||
downloaded: attachment.downloaded || attachment.preview_downloaded,
|
||||
},
|
||||
});
|
||||
y += image_height + 8.0;
|
||||
}
|
||||
|
||||
last_sender = Some(&message.sender);
|
||||
last_date = Some(message.date_unix);
|
||||
}
|
||||
|
||||
DisplayList {
|
||||
items,
|
||||
height: y.max(200.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn incoming_color() -> Color {
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
|
||||
}
|
||||
|
||||
fn outgoing_color(theme: &Theme) -> Color {
|
||||
theme.cosmic().accent.base.into()
|
||||
}
|
||||
|
||||
fn draw_bubble_background(
|
||||
renderer: &mut Renderer,
|
||||
bounds: Rectangle,
|
||||
_from_me: bool,
|
||||
color_value: Color,
|
||||
) {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: border::rounded(RADIUS),
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(color_value),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_placeholder(renderer: &mut Renderer, bounds: Rectangle, downloaded: bool) {
|
||||
let color_value = if downloaded {
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.14)
|
||||
} else {
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
|
||||
};
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: border::rounded(RADIUS),
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(color_value),
|
||||
);
|
||||
}
|
||||
|
||||
impl<Message> Widget<Message, Theme, Renderer> for Transcript<'_, Message>
|
||||
where
|
||||
Message: Clone,
|
||||
{
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<TranscriptState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(TranscriptState::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: Length::Fill,
|
||||
height: Length::Shrink,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let width = limits.max().width.max(320.0);
|
||||
let state = tree.state.downcast_mut::<TranscriptState>();
|
||||
let key = DisplayListKey {
|
||||
width_px: width.round() as u32,
|
||||
show_sender: self.show_sender,
|
||||
fingerprint: self.fingerprint,
|
||||
};
|
||||
if state.cache_key != Some(key) {
|
||||
let trace_start =
|
||||
tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
|
||||
.then(Instant::now);
|
||||
state.display_list =
|
||||
build_display_list(&self.messages, self.show_sender, width, renderer);
|
||||
state.cache_key = Some(key);
|
||||
if let Some(start) = trace_start {
|
||||
tracing::trace!(
|
||||
target: "kordophone_cosmic::transcript",
|
||||
elapsed_us = start.elapsed().as_micros(),
|
||||
messages = self.messages.len(),
|
||||
items = state.display_list.items.len(),
|
||||
width_px = key.width_px,
|
||||
"rebuilt transcript display list"
|
||||
);
|
||||
}
|
||||
}
|
||||
layout::Node::new(Size::new(width, state.display_list.height))
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let Event::Mouse(mouse::Event::ButtonPressed(button)) = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(position) = cursor.position_in(layout.bounds()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let state = tree.state.downcast_mut::<TranscriptState>();
|
||||
if state.display_list.items.is_empty() {
|
||||
state.display_list = build_display_list(
|
||||
&self.messages,
|
||||
self.show_sender,
|
||||
layout.bounds().width,
|
||||
renderer,
|
||||
);
|
||||
}
|
||||
let display_list = &state.display_list;
|
||||
let point = Point::new(position.x, position.y);
|
||||
let Some(item) = display_list.hit(point) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let action = match (&item.kind, button) {
|
||||
(DisplayKind::Text { text, .. }, mouse::Button::Right) => {
|
||||
Some(TranscriptAction::CopyText(text.clone()))
|
||||
}
|
||||
(DisplayKind::Text { text, .. }, mouse::Button::Left) => {
|
||||
first_url(text).map(TranscriptAction::OpenUrl)
|
||||
}
|
||||
(
|
||||
DisplayKind::Image {
|
||||
attachment_guid, ..
|
||||
},
|
||||
mouse::Button::Left,
|
||||
) => Some(TranscriptAction::OpenAttachment(attachment_guid.clone())),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(action) = action {
|
||||
shell.publish((self.on_action)(action));
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
|
||||
let trace_start = tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
|
||||
.then(Instant::now);
|
||||
let offset = Vector::new(layout.bounds().x, layout.bounds().y);
|
||||
let local_viewport = Rectangle::new(
|
||||
Point::new(viewport.x - offset.x, viewport.y - offset.y),
|
||||
viewport.size(),
|
||||
);
|
||||
let visible_range = display_list.visible_range(local_viewport);
|
||||
let visible_count = visible_range.len();
|
||||
renderer.with_translation(offset, |renderer| {
|
||||
for item in &display_list.items[visible_range] {
|
||||
let item_rect =
|
||||
Rectangle::new(Point::new(item.bounds.x, item.bounds.y), item.bounds.size());
|
||||
|
||||
match &item.kind {
|
||||
DisplayKind::Date { paragraph } => {
|
||||
renderer.fill_paragraph(
|
||||
paragraph,
|
||||
Point::new(item_rect.x, item_rect.y + DATE_PADDING / 2.0),
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.48),
|
||||
local_viewport,
|
||||
);
|
||||
}
|
||||
DisplayKind::Sender { paragraph } => {
|
||||
renderer.fill_paragraph(
|
||||
paragraph,
|
||||
Point::new(item_rect.x + TEXT_X_PADDING, item_rect.y + SENDER_PADDING),
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.78),
|
||||
local_viewport,
|
||||
);
|
||||
}
|
||||
DisplayKind::Text {
|
||||
paragraph, from_me, ..
|
||||
} => {
|
||||
let bubble_color = if *from_me {
|
||||
outgoing_color(theme)
|
||||
} else {
|
||||
incoming_color()
|
||||
};
|
||||
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
|
||||
renderer.fill_paragraph(
|
||||
paragraph,
|
||||
Point::new(item.content_bounds.x, item.content_bounds.y),
|
||||
if *from_me {
|
||||
theme.cosmic().accent.on.into()
|
||||
} else {
|
||||
style.text_color
|
||||
},
|
||||
local_viewport,
|
||||
);
|
||||
}
|
||||
DisplayKind::Image {
|
||||
preview_handle,
|
||||
from_me,
|
||||
downloaded,
|
||||
..
|
||||
} => {
|
||||
let bubble_color = if *from_me {
|
||||
outgoing_color(theme)
|
||||
} else {
|
||||
incoming_color()
|
||||
};
|
||||
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
|
||||
if let Some(handle) = preview_handle {
|
||||
renderer.draw_image(
|
||||
image::Image {
|
||||
handle: handle.clone(),
|
||||
border_radius: border::Radius::from(RADIUS),
|
||||
filter_method: image::FilterMethod::Linear,
|
||||
rotation: cosmic::iced::Radians(0.0),
|
||||
opacity: 1.0,
|
||||
snap: true,
|
||||
},
|
||||
item_rect,
|
||||
item_rect,
|
||||
);
|
||||
} else {
|
||||
draw_placeholder(renderer, item_rect, *downloaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(start) = trace_start {
|
||||
tracing::trace!(
|
||||
target: "kordophone_cosmic::transcript",
|
||||
elapsed_us = start.elapsed().as_micros(),
|
||||
visible_items = visible_count,
|
||||
total_items = display_list.items.len(),
|
||||
viewport_y = local_viewport.y,
|
||||
viewport_height = local_viewport.height,
|
||||
"drew transcript visible range"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let Some(position) = cursor.position_in(layout.bounds()) else {
|
||||
return mouse::Interaction::default();
|
||||
};
|
||||
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
|
||||
if display_list
|
||||
.hit(Point::new(position.x, position.y))
|
||||
.is_some()
|
||||
{
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message> From<Transcript<'a, Message>> for Element<'a, Message>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
{
|
||||
fn from(value: Transcript<'a, Message>) -> Self {
|
||||
Element::new(value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user