Private
Public Access
1
0

[cosmic] adds cosmic implementation (codex)

This commit is contained in:
2026-05-27 22:02:49 -07:00
parent 0173be356e
commit 3a113e3169
6 changed files with 8605 additions and 0 deletions

2
cosmic/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target/
/screenshots/

6879
cosmic/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
cosmic/Cargo.toml Normal file
View 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

File diff suppressed because it is too large Load Diff

21
cosmic/src/main.rs Normal file
View 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
View 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)
}
}