Compare commits

...

37 Commits

Author SHA1 Message Date
mars 73d524cddf Remove the book 2023-03-28 15:57:05 -04:00
mars f842ae608b Add screenshots to README 2023-03-28 15:40:49 -04:00
mars 3a778c0aa6 Add README 2023-03-28 15:33:43 -04:00
Iris Pupo 38fc390e10 Fix names and paths in usage docs 2023-03-24 17:12:41 -04:00
mars 4b89e781b0 Upgrade wasmtime to 3.0 2022-12-18 13:44:31 -07:00
mars 25eedc0a0d Switch default SAO UI palette to Rose Pine Moon 2022-12-07 20:19:24 -07:00
mars ad0feb2b31 Make SAO UI notification panel prettier 2022-12-07 20:05:21 -07:00
mars 508abcb0cc Replace smol with async-std in music player + notification timeouts 2022-12-07 18:11:10 -07:00
mars 9660ebb4dd Add Magpie ClosePanel message 2022-12-07 18:01:05 -07:00
mars e944caa358 Replace smol with async-std in music player 2022-12-07 16:30:37 -07:00
mars 7adc356c41 Merge pull request 'Notification daemon' (#55) from notifications into main
Reviewed-on: #55
2022-12-07 22:18:19 +00:00
mars 482a0de030 Add notifications support to SAO UI 2022-12-07 15:06:25 -07:00
mars a6b675dd11 Fix is_empty() Option<String> filtering 2022-12-06 17:54:19 -07:00
mars d073dde8ca canary-notifications protocol and panel opening 2022-12-06 17:09:50 -07:00
mars 2d098f8dea Merge branch 'main' into notifications 2022-12-06 17:01:15 -07:00
mars c06cd2d999 Update music player to use init_msg 2022-12-06 16:59:19 -07:00
mars 780f13a015 Add init_msg to Magpie CreatePanel message 2022-12-06 16:59:06 -07:00
mars c951c95650 Merge pull request 'Logging' (#53) from log into main
Reviewed-on: #53
2022-12-06 23:38:58 +00:00
mars c58ca051f4 Magpie logs info and up by default 2022-12-06 16:25:36 -07:00
mars ae7271a479 Add logging macros to Magpie 2022-12-05 21:53:38 -07:00
mars 4b46142f90 Fix use of prehash so that the RAM cache actually hits 2022-12-05 21:26:05 -07:00
mars 03e596b219 Add logging to canary crate 2022-12-05 21:17:19 -07:00
mars b9416b922f Add env_logger to Magpie 2022-12-05 21:01:01 -07:00
mars 14f077ff97 Merge branch 'main' into notifications 2022-12-05 20:27:04 -07:00
mars 109af8793b Merge pull request 'Text wrapping' (#52) from textwrap into main
Reviewed-on: #52
2022-12-06 03:26:45 +00:00
mars 38674c2580 Add configurable text color to textwrap 2022-12-05 20:26:12 -07:00
mars 70f9ca4405 Add configurable textwrap scale and line height 2022-12-05 20:20:33 -07:00
mars c7981b5064 Add Apache 2.0 license to canary-textwrap 2022-12-05 20:09:03 -07:00
mars e4a279c230 Barely-functional Canary text wrapping 2022-12-05 20:00:13 -07:00
mars 9920fea900 Better ID handling in Font + TextLayout 2022-12-05 19:59:46 -07:00
mars feec4de657 Add initial canary-textwrap crate 2022-12-05 14:44:44 -07:00
mars a5f279dfd1 Merge branch 'main' into notifications 2022-12-04 14:46:58 -07:00
mars 72d7e703c1 Load Magpie client in notifications 2022-11-29 21:56:59 -07:00
mars ce187a0381 Use find_socket() in music player 2022-11-29 18:01:17 -07:00
mars 4007c40ba6 Add find_socket() to Magpie 2022-11-29 18:01:09 -07:00
mars 0d4eb5d188 Merge branch 'main' into notifications 2022-11-29 17:28:45 -07:00
mars 756238feab Initial notifications crate 2022-11-19 20:45:35 -07:00
51 changed files with 788 additions and 212 deletions

View File

@ -2,8 +2,10 @@
members = [
"apps/magpie",
"apps/music-player",
"apps/notifications",
"apps/sandbox",
"crates/script",
"crates/textwrap",
"scripts/music-player",
"scripts/sao-ui",
]
@ -19,12 +21,13 @@ allsorts = "0.10"
anyhow = "1"
bytemuck = "1"
canary-script = { path = "crates/script" }
log = "0.4"
lyon = "1"
ouroboros = "^0.15"
parking_lot = "0.12"
prehash = "0.3.3"
slab = "0.4"
wasmtime = "0.38"
wasmtime = "3"
[dependencies.font-kit]
version = "*"

View File

@ -1,13 +1,39 @@
# Canary
Canary is a post-structuralist graphical user interface (GUI) framework that
uses standardized message-passing protocols to represent UI state instead of a
typical DOM-based GUI workflow.
Canary scripts (executed as WebAssembly) implement all of the rendering and
layout of the GUIs, so the host has little to no involvement in their
appearance. This allows virtually unlimited customization of Canary GUIs, as
scripts can be reloaded by applications with no impact on application behavior.
Canary's development has been documented on Tebibyte Media's blog:
https://tebibyte.media/blog/project/canary/
# Screenshots
<div align="center">
<figure>
<img src="./resources/sandbox-screenshot.jpg"/>
<figcaption>A screenshot of the Canary sandbox and Sword Art Online script.</figcaption>
</figure>
<figure>
<img src="./resources/music-player-screenshot.jpg"/>
<figcaption>A screenshot of the desktop music player controller using the Sword Art Online script.</figcaption>
</figure>
</div>
# Using `canary-rs`
[`canary-rs`](https://git.tebibyte.media/canary/canary-rs) is the reference
implementation for Canary. It is written in Rust, and is licensed under the
LGPLv3.
This repository (`canary-rs`) is the reference implementation for Canary. It is
written in Rust, and is licensed under the LGPLv3.
`canary-rs` is the central hub for Canary's development. It includes host-side
Rust code, helper crates for Canary hosts, wrapper crates for scripts
authored in Rust, and even the source code for the documentation that you're
currently reading.
authored in Rust, and the documentation that you're currently reading.
`canary-rs` provides a graphical "sandbox" that embeds the Canary runtime
into a lightweight graphical app. It has two purposes: first, to give
@ -16,13 +42,13 @@ benchmark, and experiment with their scripts, and second, to give Canary
embedders a live, functioning example of how Canary can be integrated into their
applications.
# Running the `canary-rs` sandbox
## Running the `canary-rs` sandbox
The sandbox requires a Canary script to run. If you don't already have one,
you can follow [these instructions](optional-building-the-sword-art-online-demonstration-ui-script)
to build the example script provided by `canary-rs`.
## Building the sandbox
### Building the sandbox
To build the sandbox from source, first make sure that you have
[installed the standard Rust toolchain](https://www.rustlang.org/tools/install),
@ -46,6 +72,8 @@ Now, the sandbox can be ran with a script:
$ cargo run --release -p canary_sandbox -- <path-to-script>
```
## Running Magpie
## (Optional) Building the Sword Art Online demonstration UI script
`canary-rs` provides an example of a fully-functioning script which, optionally,
@ -71,7 +99,7 @@ Now it can be run using the sandbox:
$ cargo run --release -p canary_sandbox -- target/wasm32-unknown-unknown/release/sao_ui_rs.wasm
```
# Using `canary-rs` as a Rust library
## Using `canary-rs` as a Rust library
***WARNING***: `canary-rs` is still in alpha development so both its API and its
version number are unstable. It is not recommended to use it in your own

View File

@ -13,8 +13,10 @@ required-features = ["service"]
anyhow = { version = "1", optional = true }
byteorder = "1.4"
canary = { path = "../..", optional = true }
env_logger = { version = "0.10", optional = true }
futures-util = { version = "0.3", optional = true, features = ["io"] }
glium = { version = "0.32", optional = true}
log = "0.4"
mio = { version = "0.8", features = ["net", "os-poll"], optional = true }
mio-signals = { version = "0.2", optional = true }
parking_lot = { version = "0.12", optional = true}
@ -24,4 +26,4 @@ slab = { version = "0.4", optional = true}
[features]
async = ["dep:futures-util"]
service = ["dep:anyhow", "dep:canary", "dep:glium", "dep:mio", "dep:mio-signals", "dep:parking_lot", "dep:slab"]
service = ["dep:anyhow", "dep:canary", "dep:env_logger", "dep:glium", "dep:mio", "dep:mio-signals", "dep:parking_lot", "dep:slab"]

View File

@ -8,6 +8,12 @@ use ipc::Ipc;
use window::{WindowMessage, WindowStore};
fn main() -> std::io::Result<()> {
env_logger::Builder::new()
.filter(None, log::LevelFilter::Info) // By default logs all info messages.
.parse_default_env()
.init();
log::info!("Initializing Magpie...");
let event_loop = EventLoopBuilder::<WindowMessage>::with_user_event().build();
let window_sender = event_loop.create_proxy();
let (ipc, ipc_sender) = Ipc::new(window_sender)?;

View File

@ -4,7 +4,7 @@
use std::collections::VecDeque;
use std::io::{Read, Write};
use std::marker::PhantomData;
use std::path::PathBuf;
use std::path::{PathBuf, Path};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
@ -26,6 +26,13 @@ pub struct CreatePanel {
pub id: PanelId,
pub protocol: String,
pub script: PathBuf,
pub init_msg: Vec<u8>,
}
/// Closes a Magpie panel with a given ID.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ClosePanel {
pub id: PanelId,
}
/// Sends a panel a message.
@ -40,6 +47,7 @@ pub struct SendMessage {
#[serde(tag = "kind")]
pub enum MagpieServerMsg {
CreatePanel(CreatePanel),
ClosePanel(ClosePanel),
SendMessage(SendMessage),
}
@ -63,7 +71,6 @@ pub type ClientMessenger<T> = Messenger<T, MagpieClientMsg, MagpieServerMsg>;
impl<T: Write> ClientMessenger<T> {
pub fn send_panel_json<O: Serialize>(&mut self, id: PanelId, msg: &O) {
let msg = serde_json::to_string(msg).unwrap();
eprintln!("Sending message: {:?}", msg);
let _ = self.send(&MagpieServerMsg::SendMessage(SendMessage {
id,
@ -211,6 +218,16 @@ impl<T: Read, I: DeserializeOwned, O> Messenger<T, I, O> {
}
}
/// Acquires the path to the Magpie socket.
///
/// Currently only joins XDG_RUNTIME_DIR with [MAGPIE_SOCK].
pub fn find_socket() -> PathBuf {
let sock_dir = std::env::var("XDG_RUNTIME_DIR").expect("XDG_RUNTIME_DIR not set");
let sock_dir = Path::new(&sock_dir);
let sock_path = sock_dir.join(MAGPIE_SOCK);
sock_path
}
#[cfg(feature = "async")]
mod async_messages {
use super::*;
@ -220,7 +237,6 @@ mod async_messages {
impl<T: AsyncWriteExt + Unpin> ClientMessenger<T> {
pub async fn send_panel_json_async<O: Serialize>(&mut self, id: PanelId, msg: &O) {
let msg = serde_json::to_string(msg).unwrap();
eprintln!("Sending message: {:?}", msg);
let _ = self
.send_async(&MagpieServerMsg::SendMessage(SendMessage {

View File

@ -47,7 +47,7 @@ impl Drop for Listener {
fn drop(&mut self) {
match std::fs::remove_file(&self.path) {
Ok(_) => {}
Err(e) => eprintln!("Could not delete UnixListener {:?}", e),
Err(e) => log::error!("Could not delete UnixListener {:?}", e),
}
}
}
@ -75,20 +75,20 @@ impl Listener {
use std::io::{Error, ErrorKind};
match UnixStream::connect(&sock_path) {
Ok(_) => {
eprintln!("Socket is already in use. Another instance of Magpie may be running.");
log::warn!("Socket is already in use. Another instance of Magpie may be running.");
let kind = ErrorKind::AddrInUse;
let error = Error::new(kind, "Socket is already in use.");
return Err(error);
}
Err(ref err) if err.kind() == ErrorKind::ConnectionRefused => {
eprintln!("Found leftover socket; removing.");
log::warn!("Found leftover socket; removing.");
std::fs::remove_file(&sock_path)?;
}
Err(ref err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
eprintln!("Making socket at: {:?}", sock_path);
log::info!("Making socket at: {:?}", sock_path);
let uds = UnixListener::bind(&sock_path)?;
let path = sock_path.to_path_buf();
Ok(Self { uds, path })
@ -120,16 +120,17 @@ pub struct Client {
impl Client {
pub fn on_readable(&mut self) -> std::io::Result<bool> {
if let Err(err) = self.messenger.flush_recv() {
eprintln!("flush_recv() error: {:?}", err);
log::error!("flush_recv() error: {:?}", err);
}
while let Some(msg) = self.messenger.try_recv() {
println!("Client #{}: {:?}", self.token.0, msg);
log::debug!("Client #{}: {:?}", self.token.0, msg);
match msg {
MagpieServerMsg::CreatePanel(CreatePanel {
id,
protocol,
script,
init_msg,
}) => {
let mut data = self.data.write();
@ -146,9 +147,16 @@ impl Client {
id: window,
protocol,
script,
init_msg,
};
let _ = self.window_sender.send_event(msg);
}
MagpieServerMsg::ClosePanel(ClosePanel { id }) => {
if let Some(id) = self.id_to_window.get(&id).copied() {
let msg = WindowMessage::CloseWindow { id };
let _ = self.window_sender.send_event(msg);
}
}
MagpieServerMsg::SendMessage(SendMessage { id, msg }) => {
if let Some(id) = self.id_to_window.get(&id).cloned() {
let msg = WindowMessage::SendMessage { id, msg };
@ -162,7 +170,7 @@ impl Client {
}
pub fn disconnect(mut self) {
println!("Client #{} disconnected", self.token.0);
log::info!("Client #{} disconnected", self.token.0);
let mut transport = self.messenger.into_transport();
let mut data = self.data.write();
@ -262,9 +270,10 @@ impl Ipc {
match self.listener.accept() {
Ok((mut connection, address)) => {
let token = Token(self.clients.vacant_key());
println!(
log::info!(
"Accepting connection (Client #{}) from {:?}",
token.0, address
token.0,
address
);
let interest = Interest::READABLE;
@ -288,7 +297,7 @@ impl Ipc {
}
} else if event.token() == self.signals_token {
while let Some(received) = self.signals.receive()? {
eprintln!("Received {:?} signal; exiting...", received);
log::info!("Received {:?} signal; exiting...", received);
let _ = self.window_sender.send_event(WindowMessage::Quit);
self.quit = true;
}
@ -302,7 +311,7 @@ impl Ipc {
self.clients.remove(event.token().0).disconnect();
}
} else {
eprintln!("Unrecognized event token: {:?}", event);
log::error!("Unrecognized event token: {:?}", event);
}
}
@ -316,7 +325,7 @@ impl Ipc {
match self.poll(&mut events, Some(wait)) {
Ok(_) => {}
Err(e) => {
eprintln!("IPC poll error: {:?}", e);
log::error!("IPC poll error: {:?}", e);
}
}
}

View File

@ -21,6 +21,7 @@ pub enum WindowMessage {
id: usize,
protocol: String,
script: PathBuf,
init_msg: Vec<u8>,
},
CloseWindow {
id: usize,
@ -194,15 +195,20 @@ impl WindowStore {
id,
protocol,
script,
init_msg,
} => {
println!("Opening window {} with script {:?}", id, script);
log::debug!("Opening window {} with script {:?}...", id, script);
let start = std::time::Instant::now();
let module = std::fs::read(script)?;
let mut script = self.runtime.load_module(&module)?;
let panel = script.create_panel(&protocol, vec![])?;
log::debug!("Instantiated window {} script in {:?}", id, start.elapsed());
let panel = script.create_panel(&protocol, init_msg)?;
log::debug!("Created window {} panel in {:?}", id, start.elapsed());
let window = Window::new(self.ipc_sender.to_owned(), id, panel, &event_loop)?;
let window_id = window.get_id();
self.windows.insert(window_id, window);
self.ipc_to_window.insert(id, window_id);
log::debug!("Opened window {} in {:?}", id, start.elapsed());
}
WindowMessage::CloseWindow { id } => {
if let Some(window_id) = self.ipc_to_window.remove(&id) {
@ -242,7 +248,7 @@ impl WindowStore {
Ok(false) => {}
Ok(true) => *control_flow = ControlFlow::Exit,
Err(err) => {
eprintln!("Error while handling message {:?}:\n{}", event, err);
log::error!("Error while handling message {:?}:\n{}", event, err);
}
},
_ => {}

View File

@ -10,12 +10,12 @@ path = "src/main.rs"
required-features = ["bin"]
[dependencies]
async-std = { version = "1.12", optional = true, features = ["attributes"] }
canary-magpie = { path = "../magpie", optional = true, features = ["async"] }
futures-util = { version = "0.3", optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
smol = { version = "1.2", optional = true }
zbus = { version = "3.5", optional = true }
[features]
bin = ["dep:canary-magpie", "dep:futures-util", "dep:smol", "dep:zbus"]
bin = ["dep:async-std", "dep:canary-magpie", "dep:futures-util", "dep:zbus"]

View File

@ -1,13 +1,12 @@
// Copyright (c) 2022 Marceline Cramer
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::path::Path;
use canary_magpie::protocol::{
ClientMessenger, CreatePanel, MagpieClientMsg, MagpieServerMsg, RecvMessage, MAGPIE_SOCK,
};
use canary_music_player::*;
use smol::net::unix::UnixStream;
use async_std::os::unix::net::UnixStream;
pub type MagpieClient = ClientMessenger<UnixStream>;
@ -125,13 +124,11 @@ async fn player_main(
player: &PlayerProxy<'_>,
magpie: &mut MagpieClient,
) -> Result<(), Box<dyn std::error::Error>> {
use futures_util::StreamExt;
use futures_util::{FutureExt, StreamExt};
let mut playback_status = player.receive_playback_status_changed().await.fuse();
let mut metadata_tracker = player.receive_metadata_changed().await.fuse();
let mut position_tracker = player.receive_position_changed().await.fuse();
let mut metadata = Metadata::update_new(magpie, player.metadata().await?).await;
use futures_util::FutureExt;
loop {
futures_util::select! {
@ -188,78 +185,76 @@ async fn player_main(
Ok(())
}
fn main() {
#[async_std::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
let module_path = args
.get(1)
.expect("Please pass a path to a Canary script!")
.to_owned();
smol::block_on(async {
let sock_dir = std::env::var("XDG_RUNTIME_DIR").expect("XDG_RUNTIME_DIR not set");
let sock_dir = Path::new(&sock_dir);
let sock_path = sock_dir.join(MAGPIE_SOCK);
let socket = UnixStream::connect(sock_path).await.unwrap();
let mut magpie = MagpieClient::new(socket);
let protocol = "tebibyte-media.desktop.music-player-controller".to_string();
let script = std::path::PathBuf::from(&module_path);
let msg = CreatePanel {
id: 0,
protocol,
script,
let sock_path = canary_magpie::protocol::find_socket();
let socket = UnixStream::connect(sock_path).await.unwrap();
let mut magpie = MagpieClient::new(socket);
let protocol = "tebibyte-media.desktop.music-player-controller".to_string();
let script = std::path::PathBuf::from(&module_path);
let msg = CreatePanel {
id: 0,
protocol,
script,
init_msg: vec![],
};
let msg = MagpieServerMsg::CreatePanel(msg);
magpie.send_async(&msg).await.unwrap();
let dbus = zbus::Connection::session().await.unwrap();
let mut first_loop = true;
let mut connected = false;
loop {
if !first_loop {
let wait = std::time::Duration::from_secs(1);
async_std::task::sleep(wait).await;
}
first_loop = false;
if connected {
println!("Disconnected from MPRIS");
let msg = InMsg::Disconnected;
magpie.send_panel_json_async(0, &msg).await;
connected = false;
}
println!("Connecting to MPRIS...");
let player = match find_player(&dbus).await {
Ok(Some(player)) => player,
Ok(None) => {
eprintln!("Couldn't find player");
continue;
}
Err(err) => {
eprintln!("D-Bus error while finding player: {:?}", err);
return;
}
};
let msg = MagpieServerMsg::CreatePanel(msg);
magpie.send_async(&msg).await.unwrap();
println!(
"Connected to \"{}\" ({})",
player.path().as_str(),
player.destination().as_str()
);
connected = true;
magpie.send_panel_json_async(0, &InMsg::Connected).await;
let dbus = zbus::Connection::session().await.unwrap();
let mut first_loop = true;
let mut connected = false;
loop {
if !first_loop {
let wait = std::time::Duration::from_secs(1);
std::thread::sleep(wait);
}
first_loop = false;
if connected {
println!("Disconnected from MPRIS");
let msg = InMsg::Disconnected;
magpie.send_panel_json_async(0, &msg).await;
connected = false;
}
println!("Connecting to MPRIS...");
let player = match find_player(&dbus).await {
Ok(Some(player)) => player,
Ok(None) => {
eprintln!("Couldn't find player");
continue;
}
Err(err) => {
eprintln!("D-Bus error while finding player: {:?}", err);
return;
}
};
println!(
"Connected to \"{}\" ({})",
player.path().as_str(),
player.destination().as_str()
);
connected = true;
magpie.send_panel_json_async(0, &InMsg::Connected).await;
match player_main(&player, &mut magpie).await {
Ok(()) => {}
Err(err) => {
eprintln!("D-Bus error while connected to player: {:?}", err);
}
match player_main(&player, &mut magpie).await {
Ok(()) => {}
Err(err) => {
eprintln!("D-Bus error while connected to player: {:?}", err);
}
}
});
}
}

View File

@ -0,0 +1,19 @@
[package]
name = "canary-notifications"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "canary-notifications"
path = "src/main.rs"
required-features = ["bin"]
[dependencies]
async-std = { version = "1.12", optional = true, features = ["attributes"] }
canary-magpie = { path = "../magpie", optional = true, features = ["async"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zbus = { version = "3.5", optional = true }
[features]
bin = ["dep:async-std", "dep:canary-magpie", "dep:zbus"]

View File

@ -0,0 +1,27 @@
// Copyright (c) 2022 Marceline Cramer
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
pub use serde;
pub use serde_json;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Contents {
/// The optional name of the application sending the notification.
pub app_name: Option<String>,
/// The summary text briefly describing the notification.
pub summary: String,
/// The optional detailed body text.
pub body: Option<String>,
/// The timeout time in milliseconds since the display of the notification
/// at which the notification should automatically close.
pub timeout: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum OutMsg {}

View File

@ -0,0 +1,143 @@
use std::collections::HashMap;
use std::future::pending;
use std::path::PathBuf;
use async_std::channel::{unbounded, Sender};
use async_std::os::unix::net::UnixStream;
use canary_magpie::protocol::*;
use canary_notifications::Contents;
use zbus::{dbus_interface, zvariant::Value, ConnectionBuilder, SignalContext};
pub type MagpieClient = ClientMessenger<UnixStream>;
pub struct Notifications {
module_path: PathBuf,
magpie_sender: Sender<MagpieServerMsg>,
next_id: u32,
}
#[dbus_interface(name = "org.freedesktop.Notifications")]
impl Notifications {
fn get_capabilities(&self) -> Vec<String> {
vec!["body", "body-markup", "actions", "icon-static"]
.into_iter()
.map(ToString::to_string)
.collect()
}
#[dbus_interface(out_args("name", "vendor", "version", "spec_version"))]
fn get_server_information(&self) -> zbus::fdo::Result<(String, String, String, String)> {
Ok((
"canary-notifications".to_string(),
"Canary Development Team".to_string(),
"0.1.0".to_string(),
"1.2".to_string(),
))
}
async fn notify(
&mut self,
app_name: String,
replaces_id: u32,
app_icon: String,
summary: String,
body: String,
actions: Vec<String>,
hints: HashMap<String, Value<'_>>,
timeout: i32,
) -> u32 {
let timeout = match timeout {
-1 => Some(5000), // default timeout
0 => None,
t => Some(t),
};
let contents = Contents {
app_name: Some(app_name).filter(|s| !s.is_empty()),
summary,
body: Some(body).filter(|s| !s.is_empty()),
timeout,
};
let id = self.next_id;
self.next_id += 1;
let msg = CreatePanel {
id,
protocol: "tebibyte-media.desktop.notification".to_string(),
script: self.module_path.to_owned(),
init_msg: serde_json::to_vec(&contents).unwrap(),
};
if let Some(delay_ms) = contents.timeout.clone() {
let delay = std::time::Duration::from_millis(delay_ms as _);
let magpie_sender = self.magpie_sender.to_owned();
async_std::task::spawn(async move {
async_std::task::sleep(delay).await;
magpie_sender
.send(MagpieServerMsg::ClosePanel(ClosePanel { id }))
.await
.unwrap();
});
}
self.magpie_sender
.send(MagpieServerMsg::CreatePanel(msg))
.await
.unwrap();
id
}
fn close_notification(&self, id: u32) {}
#[dbus_interface(signal)]
async fn notification_closed(ctx: &SignalContext<'_>, id: u32, reason: u32)
-> zbus::Result<()>;
#[dbus_interface(signal)]
async fn action_invoked(
ctx: &SignalContext<'_>,
id: u32,
action_key: String,
) -> zbus::Result<()>;
}
#[async_std::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
let module_path = args
.get(1)
.expect("Please pass a path to a Canary script!")
.to_owned()
.into();
let sock_path = find_socket();
let socket = UnixStream::connect(sock_path).await.unwrap();
let mut magpie = MagpieClient::new(socket);
let (magpie_sender, magpie_receiver) = unbounded();
let notifications = Notifications {
magpie_sender,
next_id: 0,
module_path,
};
let _ = ConnectionBuilder::session()
.unwrap()
.name("org.freedesktop.Notifications")
.unwrap()
.serve_at("/org/freedesktop/Notifications", notifications)
.unwrap()
.build()
.await
.unwrap();
async_std::task::spawn(async move {
while let Ok(msg) = magpie_receiver.recv().await {
magpie.send_async(&msg).await.unwrap();
}
});
pending::<()>().await;
}

1
book/.gitignore vendored
View File

@ -1 +0,0 @@
book

View File

@ -1,6 +0,0 @@
[book]
authors = ["marceline-cramer"]
language = "en"
multilingual = false
src = "src"
title = "Canary GUI Book"

View File

@ -1,30 +0,0 @@
# Summary
[Introduction](introduction.md)
[History](history.md)
- [Implementation](impl/README.md)
- [Development Process](impl-rs/process.md)
- [Usage](impl-rs/usage.md)
- [Backends](impl-rs/backends.md)
- [Examples](impl-rs/examples.md)
- [Ecosystem](ecosystem/README.md)
- [Messages](ecosystem/messages.md)
- [Protocols](ecosystem/protocols.md)
- [Finding Scripts](ecosystem/finding-scripts.md)
- [Fonts](ecosystem/fonts.md)
- [Localization](ecosystem/localization.md)
- [Rendering](rendering/README.md)
- [Graphics State](rendering/state.md)
- [Primitives](rendering/primitives.md)
- [Canvases](rendering/canvases.md)
- [Tessellation](rendering/tessellation.md)
- [Text](rendering/text.md)
- [Input](input/README.md)
- [Pointer](input/pointer.md)
- [Text](input/text.md)
---
[Glossary](glossary.md)
[Credits](credits.md)

View File

@ -1 +0,0 @@
# Credits

View File

@ -1 +0,0 @@
# Ecosystem

View File

@ -1 +0,0 @@
# Finding Scripts

View File

@ -1 +0,0 @@
# Fonts

View File

@ -1 +0,0 @@
# Localization

View File

@ -1 +0,0 @@
# Messages

View File

@ -1 +0,0 @@
# Protocols

View File

@ -1 +0,0 @@
# Glossary

View File

@ -1,16 +0,0 @@
# History
Canary was originally conceived in early 2021 as a WebAssembly-based,
minimalistic UI framework during the development of [Mondradiko](https://mondradiko.github.io),
where it was a dedicated subsystem of a larger game engine. When the new UI
system turned out to be even more powerful than originally expected, it was
decided that the UI code would be broken out into a separate project. The
Mondradiko community voted to name it "Canary" (the other contenders were
"Robin" and "Magpie"), and it was given [a new repository](https://github.com/mondradiko/canary).
However, before Canary could be fully fleshed-out, development on Mondradiko
was ceased and there was no reason to continue working on Canary.
In mid-2022, development was started back up, as a member project of
[Tebibyte Media](https://tebibyte.media). This new community of free software
enthusiasts had new interest in Canary apart from its usage in a larger game
engine, so development was restarted.

View File

@ -1 +0,0 @@
# Backends

View File

@ -1 +0,0 @@
# Examples

View File

@ -1,23 +0,0 @@
# Development Process
# Adding New Features
To keep Canary as minimal as possible we adopt a conservative policy for what
features are added to its specification. This is to avoid the feature-creep that
plagues large UI and UX frameworks in the long run. The following reasons are
general guidelines for what features should and should not be added to Canary.
## Reasons to add a feature
The feature provides a clear benefit to a cultural class of users. For example,
Arabic speakers will require that text can be rendered right-to-left.
The feature reduces the resource usage of scripts.
## Reasons NOT to add a feature
The feature adds more complexity to the host than is removed from scripts.
The feature only applies to certain host configurations.
The feature can be effectively emulated in a script.

View File

@ -1,4 +0,0 @@
# Implementation
This chapter discusses the design and usage of [canary-rs](https://git.tebibyte.media/canary/canary-rs),
the canonical implementation of Canary.

View File

@ -1 +0,0 @@
# Input

View File

@ -1 +0,0 @@
# Pointer

View File

@ -1 +0,0 @@
# Text

View File

@ -1 +0,0 @@
# Introduction

View File

@ -1 +0,0 @@
# Rendering

View File

@ -1 +0,0 @@
# Canvases

View File

@ -1 +0,0 @@
# Primitives

View File

@ -1 +0,0 @@
# Graphics State

View File

@ -1 +0,0 @@
# Tessellation

View File

@ -1 +0,0 @@
# Text

View File

@ -101,16 +101,22 @@ impl Panel {
}
#[repr(transparent)]
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub struct Font(u32);
impl Font {
pub fn new(family: &str) -> Self {
unsafe { Self(font_load(family.as_ptr() as u32, family.len() as u32)) }
}
/// Retrieves the script-local identifier of this font.
pub fn get_id(&self) -> u32 {
self.0
}
}
#[repr(transparent)]
#[derive(Debug)]
pub struct TextLayout(u32);
impl TextLayout {

View File

@ -0,0 +1,9 @@
[package]
name = "canary-textwrap"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
[dependencies]
canary-script = { path = "../script" }
textwrap = "0.16"

248
crates/textwrap/src/lib.rs Normal file
View File

@ -0,0 +1,248 @@
// Copyright (c) 2022 Marceline Cramer
// SPDX-License-Identifier: Apache-2.0
use std::collections::HashMap;
use canary_script::api::{DrawContext, Font, TextLayout};
use canary_script::{Color, Vec2};
#[derive(Default)]
pub struct TextCache {
layouts: Vec<OwnedText>,
fonts: Vec<HashMap<String, usize>>,
}
impl TextCache {
pub fn insert(&mut self, font: Font, text: &str) -> (usize, f32) {
let font_idx = font.get_id() as usize;
if let Some(font_cache) = self.fonts.get(font_idx) {
if let Some(layout_idx) = font_cache.get(text) {
return (*layout_idx, self.get(*layout_idx).width);
}
} else {
let new_size = font_idx + 1;
self.fonts.resize_with(new_size, Default::default);
}
let index = self.layouts.len();
let layout = TextLayout::new(&font, text);
let width = layout.get_bounds().width().max(0.0);
self.layouts.push(OwnedText {
font,
layout,
width,
});
self.fonts[font_idx].insert(text.to_string(), index);
(index, width)
}
pub fn get(&self, index: usize) -> Text<'_> {
self.layouts[index].borrow()
}
}
/// Processed text that can be laid out.
pub struct Content {
pub words: Vec<Word>,
}
impl Content {
pub fn from_plain(cache: &mut TextCache, font: Font, text: &str) -> Self {
use textwrap::word_splitters::{split_words, WordSplitter};
use textwrap::WordSeparator;
let separator = WordSeparator::new();
let words = separator.find_words(text);
// TODO: crate feature to enable hyphenation support?
let splitter = WordSplitter::NoHyphenation;
let split_words = split_words(words, &splitter);
let mut words = Vec::new();
for split_word in split_words {
let (word, word_width) = cache.insert(font, split_word.word);
let (whitespace, whitespace_width) = cache.insert(font, "_");
let (penalty, penalty_width) = cache.insert(font, split_word.penalty);
words.push(Word {
word,
word_width,
whitespace,
whitespace_width,
penalty,
penalty_width,
});
}
Self { words }
}
pub fn layout(&self, cache: &TextCache, width: f32) -> Layout {
use textwrap::wrap_algorithms::wrap_optimal_fit;
let fragments = self.words.as_slice();
let line_widths = &[width as f64];
let penalties = Default::default();
// Should never fail with reasonable input. Check [wrap_optimal_fit] docs for more info.
let wrapped_lines = wrap_optimal_fit(fragments, line_widths, &penalties).unwrap();
let mut lines = Vec::new();
for line in wrapped_lines {
lines.push(Line::from_word_line(cache, line));
}
Layout { lines }
}
}
/// An atomic fragment of processed text that is ready to be laid out.
///
/// May or may not correspond to a single English "word".
///
/// Please see [textwrap::core::Word] and [textwrap::core::Fragment] for more information.
#[derive(Debug)]
pub struct Word {
pub word: usize,
pub word_width: f32,
pub whitespace: usize,
pub whitespace_width: f32,
pub penalty: usize,
pub penalty_width: f32,
}
impl textwrap::core::Fragment for Word {
fn width(&self) -> f64 {
self.word_width as f64
}
fn whitespace_width(&self) -> f64 {
self.whitespace_width as f64
}
fn penalty_width(&self) -> f64 {
self.penalty_width as f64
}
}
#[derive(Debug)]
pub struct OwnedText {
pub font: Font,
pub layout: TextLayout,
pub width: f32,
}
impl OwnedText {
pub fn borrow(&self) -> Text<'_> {
Text {
font: self.font,
layout: &self.layout,
width: self.width,
}
}
}
/// A single piece of renderable text.
#[derive(Debug)]
pub struct Text<'a> {
/// The font that this fragment has been laid out with.
pub font: Font,
/// The draw-ready [TextLayout] of this fragment.
pub layout: &'a TextLayout,
/// The width of this text.
pub width: f32,
}
/// A finished, wrapped text layout.
pub struct Layout {
pub lines: Vec<Line>,
}
impl Layout {
pub fn draw(
&self,
cache: &TextCache,
ctx: &DrawContext,
scale: f32,
line_height: f32,
color: Color,
) {
let mut cursor = Vec2::ZERO;
for line in self.lines.iter() {
let ctx = ctx.with_offset(cursor);
line.draw(cache, &ctx, scale, color);
cursor.y += line_height;
}
}
}
/// A finished line of a layout.
pub struct Line {
pub fragments: Vec<Fragment>,
}
impl Line {
pub fn from_word_line(cache: &TextCache, words: &[Word]) -> Self {
let last_idx = words.len() - 1;
let mut fragments = Vec::new();
let mut add_word = |index: usize, hidden: bool| {
let text = cache.get(index);
fragments.push(Fragment {
font: text.font,
text: index,
offset: Vec2::ZERO,
advance: text.width,
hidden,
});
};
for (idx, word) in words.iter().enumerate() {
add_word(word.word, false);
if idx == last_idx {
add_word(word.penalty, false);
} else {
add_word(word.whitespace, true);
}
}
Self { fragments }
}
pub fn draw(&self, cache: &TextCache, ctx: &DrawContext, scale: f32, color: Color) {
let mut cursor = Vec2::ZERO;
for fragment in self.fragments.iter() {
if !fragment.hidden {
let text = cache.get(fragment.text);
let offset = cursor + fragment.offset;
ctx.draw_text_layout(text.layout, offset, scale, color);
}
cursor.x += fragment.advance * scale;
}
}
}
/// A finished fragment in a layout.
pub struct Fragment {
/// The font of this fragment.
pub font: Font,
/// The index into the [TextCache] of the content of this fragment.
pub text: usize,
/// The offset for drawing the text layout.
pub offset: Vec2,
/// The horizontal advance to draw the next fragment.
pub advance: f32,
/// Whether this fragment should be skipped while drawing.
pub hidden: bool,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -11,7 +11,9 @@ crate-type = ["cdylib"]
glam = "^0.21"
keyframe = "1"
canary-music-player = { path = "../../apps/music-player" }
canary-notifications = { path = "../../apps/notifications" }
canary-script = { path = "../../crates/script" }
canary-textwrap = { path = "../../crates/textwrap" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wee_alloc = "^0.4"

View File

@ -7,6 +7,7 @@ static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
pub mod anim;
pub mod main_menu;
pub mod music_player;
pub mod notifications;
pub mod style;
pub mod widgets;
@ -14,6 +15,7 @@ use api::*;
use canary_script::*;
use main_menu::MainMenuPanel;
use music_player::MusicPlayerPanel;
use notifications::NotificationPanel;
use widgets::Widget;
export_abi!(bind_panel_impl);
@ -24,6 +26,7 @@ fn bind_panel_impl(panel: Panel, protocol: Message, msg: Message) -> Box<dyn Pan
match protocol.as_str() {
"tebibyte-media.desktop.music-player-controller" => MusicPlayerPanel::bind(panel, msg),
"tebibyte-media.desktop.notification" => NotificationPanel::bind(panel, msg),
"wip-dialog" => ConfirmationDialogPanel::bind(panel, msg),
_ => MainMenuPanel::bind(panel, msg),
}

View File

@ -0,0 +1,130 @@
// Copyright (c) 2022 Marceline Crmaer
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::widgets::prelude::*;
use api::*;
use canary_script::*;
use canary_textwrap::{Content, Layout, TextCache};
use dialog::{DialogBodyStyle, DialogHeaderStyle};
use shell::Offset;
use text::{Label, LabelText};
pub struct NotificationStyle {
pub header: DialogHeaderStyle,
pub body: DialogBodyStyle,
pub rounding: f32,
}
impl Default for NotificationStyle {
fn default() -> Self {
Self {
header: DialogHeaderStyle {
height: 12.0,
..Default::default()
},
body: Default::default(),
rounding: THEME.metrics.surface_rounding,
}
}
}
pub struct NotificationPanel {
panel: Panel,
style: NotificationStyle,
summary: Label,
text_cache: TextCache,
body: Content,
body_layout: Layout,
header_rect: Rect,
body_rect: Rect,
}
impl PanelImpl for NotificationPanel {
fn update(&mut self, dt: f32) {}
fn draw(&mut self) {
let ctx = DrawContext::new(self.panel);
ctx.draw_partially_rounded_rect(
CornerFlags::TOP,
self.header_rect,
self.style.rounding,
self.style.header.color,
);
ctx.draw_partially_rounded_rect(
CornerFlags::BOTTOM,
self.body_rect,
self.style.rounding,
self.style.body.color,
);
self.summary.draw(&ctx);
let ctx = ctx.with_offset(Vec2::new(5.0, 20.0));
self.body_layout
.draw(&self.text_cache, &ctx, 5.0, 8.0, THEME.palette.text);
}
fn on_resize(&mut self, new_size: Vec2) {
let style = &self.style;
let width = new_size.x;
let body_height = new_size.y - style.header.height;
let body_height = body_height.max(0.0);
let header_size = Vec2::new(width, style.header.height);
let body_size = Vec2::new(width, body_height);
self.header_rect = Rect::from_xy_size(Vec2::ZERO, header_size);
self.body_rect = Rect::from_xy_size(self.header_rect.bl(), body_size);
let width = (new_size.x - 10.0) / 5.0;
self.body_layout = self.body.layout(&mut self.text_cache, width);
}
fn on_cursor_event(&mut self, kind: CursorEventKind, at: Vec2) {}
fn on_message(&mut self, msg: Message) {}
}
impl NotificationPanel {
pub fn bind(panel: Panel, msg: Message) -> Box<dyn PanelImpl> {
let msg = msg.to_vec();
let msg: canary_notifications::Contents = serde_json::from_slice(&msg).unwrap();
let style = NotificationStyle::default();
let font = style.header.text_font;
let text = msg.summary;
let text = LabelText { font, text };
let scale = style.header.height * style.header.text_scale_factor;
let summary = Label::new(
text,
text::HorizontalAlignment::Left,
scale,
style.header.text_color,
5.0,
5.0,
style.header.height * (1.0 - style.header.text_baseline),
);
let font = Font::new(crate::CONTENT_FONT);
let text = msg.body.unwrap_or(String::new());
let mut text_cache = TextCache::default();
let body = Content::from_plain(&mut text_cache, font, &text);
let body_layout = body.layout(&text_cache, 0.0);
let header_rect = Default::default();
let body_rect = Default::default();
Box::new(Self {
style,
panel,
summary,
text_cache,
body,
body_layout,
header_rect,
body_rect,
})
}
}

View File

@ -166,7 +166,7 @@ pub struct Theme {
/// The global theme.
pub const THEME: Theme = Theme {
palette: ARCTICA_PALETTE,
palette: ROSE_PINE_MOON_PALETTE,
metrics: Metrics {
surface_rounding: 5.0,
},

View File

@ -19,11 +19,13 @@ pub mod wasmtime;
/// Currently, only ever creates [wasmtime::WasmtimeBackend].
pub fn make_default_backend() -> anyhow::Result<Box<dyn Backend>> {
let backend = wasmtime::WasmtimeBackend::new()?;
log::info!("Created default ({}) backend", backend.name());
Ok(Box::new(backend))
}
/// A WebAssembly runtime backend.
pub trait Backend {
fn name(&self) -> &'static str;
fn load_module(&self, abi: Arc<ScriptAbi>, module: &[u8]) -> anyhow::Result<Arc<dyn Instance>>;
}

View File

@ -2,20 +2,21 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
use std::collections::{hash_map::DefaultHasher, HashMap};
use std::hash::Hasher;
use std::hash::{Hasher, BuildHasherDefault};
use std::ops::DerefMut;
use std::time::Instant;
use super::{Arc, Backend, Instance, PanelId, ScriptAbi};
use crate::DrawCommand;
use canary_script::{Color, CursorEventKind, Rect, Vec2};
use parking_lot::Mutex;
use prehash::{DefaultPrehasher, Prehashed, Prehasher};
use prehash::Passthru;
type Caller<'a> = wasmtime::Caller<'a, Arc<ScriptAbi>>;
type Store = wasmtime::Store<Arc<ScriptAbi>>;
type Linker = wasmtime::Linker<Arc<ScriptAbi>>;
type ModuleCache = Mutex<HashMap<Prehashed<u64>, wasmtime::Module, DefaultPrehasher>>;
type ModuleCache = Mutex<HashMap<u64, wasmtime::Module, BuildHasherDefault<Passthru>>>;
pub struct WasmtimeBackend {
engine: wasmtime::Engine,
@ -24,6 +25,8 @@ pub struct WasmtimeBackend {
impl WasmtimeBackend {
pub fn new() -> anyhow::Result<Self> {
log::info!("Creating wasmtime backend");
let mut config = wasmtime::Config::new();
config.wasm_simd(true);
config.wasm_bulk_memory(true);
@ -40,21 +43,31 @@ impl WasmtimeBackend {
}
impl Backend for WasmtimeBackend {
fn name(&self) -> &'static str {
"wasmtime"
}
fn load_module(&self, abi: Arc<ScriptAbi>, module: &[u8]) -> anyhow::Result<Arc<dyn Instance>> {
let start = Instant::now();
let mut hasher = DefaultHasher::new();
hasher.write(module);
let hashed = hasher.finish();
let hash = hasher.finish();
let fmt_hash = format!("{:x}", hash);
let prehasher = DefaultPrehasher::new();
let prehashed = prehasher.prehash(hashed);
log::debug!("Loading module (hash: {})", fmt_hash);
let mut cache = self.module_cache.lock();
let module = if let Some(module) = cache.get(&prehashed) {
let module = if let Some(module) = cache.get(&hash) {
log::debug!("Module load cache hit (hash: {})", fmt_hash);
module
} else {
log::debug!("Module load cache miss; building (hash: {})", fmt_hash);
let start = Instant::now();
let module = wasmtime::Module::new(&self.engine, module)?;
cache.insert(prehashed, module);
cache.get(&prehashed).unwrap()
cache.insert(hash, module);
log::debug!("Built module in {:?} (hash: {})", start.elapsed(), fmt_hash);
cache.get(&hash).unwrap()
};
let mut store = wasmtime::Store::new(&self.engine, abi);
@ -80,6 +93,12 @@ impl Backend for WasmtimeBackend {
let instance = Arc::new(instance);
log::debug!(
"Loaded module in {:?} (hash: {})",
start.elapsed(),
fmt_hash
);
Ok(instance)
}
}

View File

@ -21,6 +21,8 @@ pub struct Runtime {
impl Runtime {
pub fn new(backend: Box<dyn Backend>) -> anyhow::Result<Self> {
log::info!("Initializing runtime with {} backend", backend.name());
Ok(Self {
backend,
font_store: Arc::new(FontStore::new()),

View File

@ -220,6 +220,7 @@ impl Default for FontStore {
impl FontStore {
pub fn new() -> Self {
log::info!("Initializing FontStore");
let source = font_kit::source::SystemSource::new();
let source = Box::new(source);
@ -241,14 +242,14 @@ impl FontStore {
use font_kit::handle::Handle;
use font_kit::properties::Properties;
println!("loading font family {}", title);
log::info!("Finding font by family: {}", title);
let font_handle = self
.source
.select_best_match(&[FamilyName::Title(title.to_string())], &Properties::new())
.unwrap();
println!("loading font file: {:?}", font_handle);
log::info!("Loading font file: {:?}", font_handle);
let font_data = if let Handle::Path {
path,