commit
6fcf0efd49
File diff suppressed because it is too large
Load Diff
|
|
@ -14,8 +14,29 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
graphite-editor = { workspace = true }
|
graphite-editor = { workspace = true }
|
||||||
|
graph-craft = { workspace = true }
|
||||||
|
wgpu-executor = { workspace = true }
|
||||||
|
|
||||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "tokio"] }
|
iced_wgpu = { git = "https://github.com/iced-rs/iced", branch = "master", features = ["image"] }
|
||||||
|
iced_runtime = { git = "https://github.com/iced-rs/iced", branch = "master" }
|
||||||
|
iced_widget = { git = "https://github.com/iced-rs/iced", branch = "master", features = ["wgpu", "lazy", "image"] }
|
||||||
|
iced_graphics = { git = "https://github.com/iced-rs/iced", branch = "master" }
|
||||||
|
|
||||||
|
winit = { workspace = true, features = ["wayland-csd-adwaita-notitlebar", "serde"] }
|
||||||
|
wgpu = { workspace = true }
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
pollster = "0.4"
|
||||||
|
|
||||||
|
dirs = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
|
include_dir = { workspace = true }
|
||||||
rand = { workspace = true, features = ["thread_rng"] }
|
rand = { workspace = true, features = ["thread_rng"] }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
resvg = { workspace = true }
|
||||||
|
rfd = { workspace = true }
|
||||||
|
usvg = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
|
window_clipboard = "0.5"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,487 @@
|
||||||
|
use graphite_editor::application::{Editor, Environment, Host, Platform};
|
||||||
|
use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
|
||||||
|
use graphite_editor::messages::layout::utility_types::widget_prelude::{LayoutTarget, WidgetId};
|
||||||
|
use graphite_editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily, FontCatalogStyle};
|
||||||
|
use graphite_editor::messages::prelude::*;
|
||||||
|
use include_dir::{Dir, include_dir};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
|
use std::thread;
|
||||||
|
use iced_widget::core::{Alignment, Element as CoreElement, Length, Theme};
|
||||||
|
use iced_widget::shader::Shader;
|
||||||
|
use iced_widget::{column, container, opaque, row, scrollable, stack, text};
|
||||||
|
use rand::Rng;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::layout::LayoutStore;
|
||||||
|
use crate::viewport_widget::ViewportProgram;
|
||||||
|
use crate::widgets;
|
||||||
|
|
||||||
|
static DEMO_ARTWORK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../demo-artwork");
|
||||||
|
|
||||||
|
pub type Element<'a, Message> = CoreElement<'a, Message, Theme, iced_wgpu::Renderer>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
Init,
|
||||||
|
WidgetClicked { layout_target: LayoutTarget, widget_id: WidgetId, value: serde_json::Value },
|
||||||
|
KeyDown { key: Key, modifiers: ModifierKeys, repeat: bool },
|
||||||
|
KeyUp { key: Key, modifiers: ModifierKeys },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
editor: Editor,
|
||||||
|
store: LayoutStore,
|
||||||
|
frontend_log: Vec<String>,
|
||||||
|
dialog: Option<DialogHeader>,
|
||||||
|
artwork_texture: Option<Arc<wgpu::Texture>>,
|
||||||
|
async_results: Receiver<AsyncResult>,
|
||||||
|
async_sender: Sender<AsyncResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum AsyncResult {
|
||||||
|
FontCatalog(FontCatalog),
|
||||||
|
FontData { font_family: String, font_style: String, data: Vec<u8> },
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DialogHeader {
|
||||||
|
title: String,
|
||||||
|
icon: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> (Self, Vec<Message>) {
|
||||||
|
let environment = Environment {
|
||||||
|
platform: Platform::Desktop,
|
||||||
|
host: detect_host(),
|
||||||
|
};
|
||||||
|
let seed = rand::rng().random();
|
||||||
|
let editor = Editor::new(environment, seed);
|
||||||
|
|
||||||
|
let (async_sender, async_results) = channel();
|
||||||
|
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
editor,
|
||||||
|
store: LayoutStore::default(),
|
||||||
|
frontend_log: Vec::new(),
|
||||||
|
dialog: None,
|
||||||
|
artwork_texture: None,
|
||||||
|
async_results,
|
||||||
|
async_sender,
|
||||||
|
},
|
||||||
|
vec![Message::Init],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_async_results(&mut self) {
|
||||||
|
while let Ok(result) = self.async_results.try_recv() {
|
||||||
|
match result {
|
||||||
|
AsyncResult::FontCatalog(catalog) => {
|
||||||
|
tracing::info!(families = catalog.0.len(), "FontCatalog received, dispatching to editor");
|
||||||
|
let responses = self.editor.handle_message(PortfolioMessage::FontCatalogLoaded { catalog });
|
||||||
|
self.absorb(responses);
|
||||||
|
}
|
||||||
|
AsyncResult::FontData { font_family, font_style, data } => {
|
||||||
|
tracing::info!(family = %font_family, style = %font_style, bytes = data.len(), "FontData received, dispatching to editor");
|
||||||
|
let responses = self.editor.handle_message(PortfolioMessage::FontLoaded { font_family, font_style, data });
|
||||||
|
self.absorb(responses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_artwork_texture(&mut self, texture: Option<Arc<wgpu::Texture>>) {
|
||||||
|
self.artwork_texture = texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_viewport_bounds(&mut self, x: f64, y: f64, width: f64, height: f64, scale: f64) {
|
||||||
|
let responses = self.editor.handle_message(ViewportMessage::Update { x, y, width, height, scale });
|
||||||
|
self.absorb(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_or_empty(&self, target: LayoutTarget) -> Element<'_, Message> {
|
||||||
|
match self.store.get(target) {
|
||||||
|
Some(layout) => widgets::render_layout(target, layout),
|
||||||
|
None => text("").into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, message: Message) {
|
||||||
|
match message {
|
||||||
|
Message::Init => {
|
||||||
|
let responses = self.editor.handle_message(PortfolioMessage::Init);
|
||||||
|
self.absorb(responses);
|
||||||
|
}
|
||||||
|
Message::WidgetClicked { layout_target, widget_id, value } => {
|
||||||
|
let commit = self.editor.handle_message(LayoutMessage::WidgetValueCommit { layout_target, widget_id, value: value.clone() });
|
||||||
|
self.absorb(commit);
|
||||||
|
let update = self.editor.handle_message(LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value });
|
||||||
|
self.absorb(update);
|
||||||
|
let end = self.editor.handle_message(DocumentMessage::EndTransaction);
|
||||||
|
self.absorb(end);
|
||||||
|
}
|
||||||
|
Message::KeyDown { key, modifiers, repeat } => {
|
||||||
|
let responses = self.editor.handle_message(InputPreprocessorMessage::KeyDown {
|
||||||
|
key,
|
||||||
|
key_repeat: repeat,
|
||||||
|
modifier_keys: modifiers,
|
||||||
|
});
|
||||||
|
self.absorb(responses);
|
||||||
|
}
|
||||||
|
Message::KeyUp { key, modifiers } => {
|
||||||
|
let responses = self.editor.handle_message(InputPreprocessorMessage::KeyUp {
|
||||||
|
key,
|
||||||
|
key_repeat: false,
|
||||||
|
modifier_keys: modifiers,
|
||||||
|
});
|
||||||
|
self.absorb(responses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absorb(&mut self, responses: Vec<FrontendMessage>) {
|
||||||
|
for message in responses {
|
||||||
|
let kind = message.to_discriminant().local_name();
|
||||||
|
match message {
|
||||||
|
FrontendMessage::UpdateLayout { layout_target, diff } => {
|
||||||
|
tracing::info!(?layout_target, count = diff.len(), "UpdateLayout");
|
||||||
|
self.frontend_log.push(format!("UpdateLayout → {layout_target:?} ({} diffs)", diff.len()));
|
||||||
|
for d in diff {
|
||||||
|
self.store.apply(layout_target, d);
|
||||||
|
}
|
||||||
|
if matches!(layout_target, LayoutTarget::DialogColumn1) {
|
||||||
|
if let Some(layout) = self.store.get(LayoutTarget::DialogColumn1) {
|
||||||
|
let mut buf = String::new();
|
||||||
|
collect_text(layout, &mut buf);
|
||||||
|
if !buf.is_empty() {
|
||||||
|
tracing::info!(text = %buf, "DialogColumn1 text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrontendMessage::DisplayDialog { title, icon } => {
|
||||||
|
tracing::info!(%title, "DisplayDialog");
|
||||||
|
self.frontend_log.push(format!("DisplayDialog → {title}"));
|
||||||
|
self.dialog = Some(DialogHeader { title, icon });
|
||||||
|
}
|
||||||
|
FrontendMessage::DialogClose => {
|
||||||
|
tracing::info!("DialogClose");
|
||||||
|
self.frontend_log.push(kind.to_string());
|
||||||
|
self.dialog = None;
|
||||||
|
}
|
||||||
|
FrontendMessage::TriggerFetchAndOpenDocument { name, filename } => {
|
||||||
|
tracing::info!(%filename, "TriggerFetchAndOpenDocument");
|
||||||
|
self.frontend_log.push(format!("TriggerFetchAndOpenDocument → {filename}"));
|
||||||
|
match DEMO_ARTWORK.get_file(&filename) {
|
||||||
|
Some(file) => {
|
||||||
|
tracing::info!(bytes = file.contents().len(), "demo bundle hit, opening");
|
||||||
|
let content = file.contents().to_vec();
|
||||||
|
let path = PathBuf::from(&filename);
|
||||||
|
let _ = name;
|
||||||
|
let opened = self.editor.handle_message(PortfolioMessage::OpenFile { path, content });
|
||||||
|
self.absorb(opened);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(%filename, "bundled demo artwork missing");
|
||||||
|
self.frontend_log.push(format!("bundled demo artwork '{filename}' missing"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrontendMessage::TriggerFontCatalogLoad => {
|
||||||
|
tracing::info!("TriggerFontCatalogLoad → spawning fetch");
|
||||||
|
self.frontend_log.push(String::from("TriggerFontCatalogLoad → fetching"));
|
||||||
|
spawn_font_catalog_fetch(self.async_sender.clone());
|
||||||
|
}
|
||||||
|
FrontendMessage::TriggerFontDataLoad { font, url } => {
|
||||||
|
tracing::info!(family = %font.font_family, style = %font.font_style, %url, "TriggerFontDataLoad → spawning fetch");
|
||||||
|
self.frontend_log.push(format!("TriggerFontDataLoad → {} {}", font.font_family, font.font_style));
|
||||||
|
spawn_font_data_fetch(self.async_sender.clone(), font.font_family, font.font_style, url);
|
||||||
|
}
|
||||||
|
_ => self.frontend_log.push(kind.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(&self) -> Element<'_, Message> {
|
||||||
|
let menu_bar: Element<'_, Message> = self.layout_or_empty(LayoutTarget::MenuBar);
|
||||||
|
let tool_shelf = self.layout_or_empty(LayoutTarget::ToolShelf);
|
||||||
|
let working_colors = self.layout_or_empty(LayoutTarget::WorkingColors);
|
||||||
|
let tool_options = self.layout_or_empty(LayoutTarget::ToolOptions);
|
||||||
|
let document_bar = self.layout_or_empty(LayoutTarget::DocumentBar);
|
||||||
|
let node_graph_control = self.layout_or_empty(LayoutTarget::NodeGraphControlBar);
|
||||||
|
let layers_control_left = self.layout_or_empty(LayoutTarget::LayersPanelControlLeftBar);
|
||||||
|
let layers_control_right = self.layout_or_empty(LayoutTarget::LayersPanelControlRightBar);
|
||||||
|
let layers_bottom = self.layout_or_empty(LayoutTarget::LayersPanelBottomBar);
|
||||||
|
let status_hints = self.layout_or_empty(LayoutTarget::StatusBarHints);
|
||||||
|
let status_info = self.layout_or_empty(LayoutTarget::StatusBarInfo);
|
||||||
|
let welcome = self.layout_or_empty(LayoutTarget::WelcomeScreenButtons);
|
||||||
|
|
||||||
|
let menu_bar = container(menu_bar).padding(4).width(Length::Fill);
|
||||||
|
|
||||||
|
let left_strip = container(column![container(tool_shelf).padding(4), container(working_colors).padding(4)].spacing(0))
|
||||||
|
.width(Length::Fixed(48.0))
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
let canvas_top = container(row![tool_options, document_bar].spacing(8).align_y(Alignment::Center))
|
||||||
|
.padding(4)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let canvas_center: Element<'_, Message> = if self.artwork_texture.is_some() {
|
||||||
|
let viewport_program = ViewportProgram { texture: self.artwork_texture.clone() };
|
||||||
|
Shader::new(viewport_program).width(Length::Fill).height(Length::Fill).into()
|
||||||
|
} else {
|
||||||
|
container(welcome).center_x(Length::Fill).center_y(Length::Fill).width(Length::Fill).height(Length::Fill).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let center_column = column![canvas_top, canvas_center].spacing(0).width(Length::Fill).height(Length::Fill);
|
||||||
|
|
||||||
|
let right_panel = container(
|
||||||
|
column![
|
||||||
|
container(node_graph_control).padding(4).width(Length::Fill),
|
||||||
|
container(row![layers_control_left, layers_control_right].spacing(8)).padding(4).width(Length::Fill),
|
||||||
|
container(scrollable(column![].spacing(0))).padding(4).width(Length::Fill).height(Length::Fill),
|
||||||
|
container(layers_bottom).padding(4).width(Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(0),
|
||||||
|
)
|
||||||
|
.width(Length::Fixed(320.0))
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
let body_row = row![left_strip, center_column, right_panel].spacing(0).width(Length::Fill).height(Length::Fill);
|
||||||
|
|
||||||
|
let status_bar = container(row![status_hints, container(text("")).width(Length::Fill), status_info].spacing(8).align_y(Alignment::Center))
|
||||||
|
.padding(4)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let main: Element<'_, Message> = container(column![menu_bar, body_row, status_bar].spacing(0)).width(Length::Fill).height(Length::Fill).into();
|
||||||
|
|
||||||
|
let Some(header) = &self.dialog else {
|
||||||
|
return main;
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon = if header.icon.is_empty() { String::new() } else { format!("[{}] ", header.icon) };
|
||||||
|
let title_text = text(format!("{icon}{}", header.title)).size(15);
|
||||||
|
|
||||||
|
let title_bar = container(title_text)
|
||||||
|
.padding([10, 16])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(dialog_title_bar_style);
|
||||||
|
|
||||||
|
let mut body_column = column![].spacing(12);
|
||||||
|
for target in [LayoutTarget::DialogColumn1, LayoutTarget::DialogColumn2] {
|
||||||
|
if let Some(layout) = self.store.get(target) {
|
||||||
|
body_column = body_column.push(widgets::render_layout(target, layout));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body = container(body_column).padding(20).width(Length::Fill);
|
||||||
|
|
||||||
|
let footer: Element<'_, Message> = if let Some(layout) = self.store.get(LayoutTarget::DialogButtons) {
|
||||||
|
container(row![container(text("")).width(Length::Fill), widgets::render_layout(LayoutTarget::DialogButtons, layout)].spacing(8).align_y(Alignment::Center))
|
||||||
|
.padding([10, 16])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(dialog_footer_style)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text("").into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let card_column = column![title_bar, body, footer].spacing(0);
|
||||||
|
let card: Element<'_, Message> = container(card_column).style(dialog_card_style).width(Length::FillPortion(14)).into();
|
||||||
|
|
||||||
|
let positioned = column![
|
||||||
|
iced_widget::Space::new().height(Length::FillPortion(2)),
|
||||||
|
row![
|
||||||
|
iced_widget::Space::new().width(Length::FillPortion(3)),
|
||||||
|
card,
|
||||||
|
iced_widget::Space::new().width(Length::FillPortion(3)),
|
||||||
|
]
|
||||||
|
.width(Length::Fill),
|
||||||
|
iced_widget::Space::new().height(Length::FillPortion(3)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let scrim = container(positioned).width(Length::Fill).height(Length::Fill).style(dialog_scrim_style);
|
||||||
|
|
||||||
|
stack![main, opaque(scrim)].into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_text(layout: &graphite_editor::messages::layout::utility_types::widget_prelude::Layout, buf: &mut String) {
|
||||||
|
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
|
||||||
|
for group in &layout.0 {
|
||||||
|
collect_text_group(group, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_text_group(group: &graphite_editor::messages::layout::utility_types::widget_prelude::LayoutGroup, buf: &mut String) {
|
||||||
|
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
|
||||||
|
match group {
|
||||||
|
LayoutGroup::Column(WidgetColumn { widgets }) | LayoutGroup::Row(WidgetRow { widgets }) => {
|
||||||
|
for instance in widgets {
|
||||||
|
collect_text_widget(&instance.widget, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayoutGroup::Section(WidgetSection { layout, .. }) => collect_text(layout, buf),
|
||||||
|
LayoutGroup::Table(WidgetTable { rows, .. }) => {
|
||||||
|
for row in rows {
|
||||||
|
for instance in row {
|
||||||
|
collect_text_widget(&instance.widget, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_text_widget(widget: &graphite_editor::messages::layout::utility_types::widget_prelude::Widget, buf: &mut String) {
|
||||||
|
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
|
||||||
|
match widget {
|
||||||
|
Widget::TextLabel(w) => {
|
||||||
|
if !buf.is_empty() {
|
||||||
|
buf.push_str(" | ");
|
||||||
|
}
|
||||||
|
buf.push_str(&w.value);
|
||||||
|
}
|
||||||
|
Widget::TextInput(w) => {
|
||||||
|
if !buf.is_empty() {
|
||||||
|
buf.push_str(" | ");
|
||||||
|
}
|
||||||
|
buf.push_str(&w.value);
|
||||||
|
}
|
||||||
|
Widget::TextAreaInput(w) => {
|
||||||
|
if !buf.is_empty() {
|
||||||
|
buf.push_str(" | ");
|
||||||
|
}
|
||||||
|
buf.push_str(&w.value);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_LIST_API: &str = "https://api.graphite.art/font-list";
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct FontListResponse {
|
||||||
|
items: Vec<FontListEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct FontListEntry {
|
||||||
|
family: String,
|
||||||
|
variants: Vec<String>,
|
||||||
|
files: std::collections::HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_font_catalog_fetch(sender: Sender<AsyncResult>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let response = match reqwest::blocking::get(FONT_LIST_API) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("font catalog fetch failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let parsed: FontListResponse = match response.json() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("font catalog parse failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let catalog = FontCatalog(
|
||||||
|
parsed
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let styles = entry
|
||||||
|
.variants
|
||||||
|
.iter()
|
||||||
|
.filter_map(|variant| {
|
||||||
|
let weight = if variant == "regular" || variant == "italic" {
|
||||||
|
400
|
||||||
|
} else {
|
||||||
|
variant.trim_end_matches("italic").parse::<u32>().ok()?
|
||||||
|
};
|
||||||
|
let italic = variant.ends_with("italic");
|
||||||
|
let url = entry.files.get(variant)?.replace("http://", "https://");
|
||||||
|
Some(FontCatalogStyle { weight, italic, url })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
FontCatalogFamily { name: entry.family, styles }
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let _ = sender.send(AsyncResult::FontCatalog(catalog));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_font_data_fetch(sender: Sender<AsyncResult>, font_family: String, font_style: String, url: String) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let response = match reqwest::blocking::get(&url) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("font data fetch for {font_family} {font_style} failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bytes = match response.bytes() {
|
||||||
|
Ok(b) => b.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("font data read for {font_family} {font_style} failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = sender.send(AsyncResult::FontData { font_family, font_style, data: bytes });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_scrim_style(_: &Theme) -> iced_widget::container::Style {
|
||||||
|
use iced_widget::core::{Background, Color};
|
||||||
|
iced_widget::container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.55))),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_card_style(_: &Theme) -> iced_widget::container::Style {
|
||||||
|
use iced_widget::core::{Background, Border, Color};
|
||||||
|
iced_widget::container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb(0.13, 0.13, 0.13))),
|
||||||
|
border: Border {
|
||||||
|
color: Color::from_rgb(0.28, 0.28, 0.28),
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_title_bar_style(_: &Theme) -> iced_widget::container::Style {
|
||||||
|
use iced_widget::core::{Background, Color};
|
||||||
|
iced_widget::container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.18))),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_footer_style(_: &Theme) -> iced_widget::container::Style {
|
||||||
|
use iced_widget::core::{Background, Color};
|
||||||
|
iced_widget::container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.10))),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_dialog_target(target: LayoutTarget) -> bool {
|
||||||
|
matches!(target, LayoutTarget::DialogColumn1 | LayoutTarget::DialogColumn2 | LayoutTarget::DialogButtons)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_host() -> Host {
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
Host::Mac
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
Host::Windows
|
||||||
|
} else {
|
||||||
|
Host::Linux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
use raw_window_handle::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct ClipboardHandle {
|
||||||
|
inner: Mutex<window_clipboard::Clipboard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipboardHandle {
|
||||||
|
pub fn new_from_raw(window_handle: RawWindowHandle, display_handle: RawDisplayHandle) -> Option<Self> {
|
||||||
|
let provider = RawHandles { window: window_handle, display: display_handle };
|
||||||
|
let connected = unsafe { window_clipboard::Clipboard::connect(&provider) };
|
||||||
|
match connected {
|
||||||
|
Ok(clipboard) => Some(Self { inner: Mutex::new(clipboard) }),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to connect system clipboard: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_text(&self) -> Option<String> {
|
||||||
|
let guard = match self.inner.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("clipboard mutex poisoned: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match guard.read() {
|
||||||
|
Ok(text) => Some(text),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to read clipboard: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_text(&self, text: &str) {
|
||||||
|
let mut guard = match self.inner.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("clipboard mutex poisoned: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = guard.write(text.to_string()) {
|
||||||
|
tracing::warn!("failed to write clipboard: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_selection(&self) -> Option<String> {
|
||||||
|
let guard = match self.inner.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("clipboard mutex poisoned: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(result) = guard.read_primary() {
|
||||||
|
match result {
|
||||||
|
Ok(text) => Some(text),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to read primary selection: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match guard.read() {
|
||||||
|
Ok(text) => Some(text),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to read clipboard fallback for selection: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_selection(&self, text: &str) {
|
||||||
|
let mut guard = match self.inner.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("clipboard mutex poisoned: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(result) = guard.write_primary(text.to_string()) {
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::warn!("failed to write primary selection: {e}");
|
||||||
|
}
|
||||||
|
} else if let Err(e) = guard.write(text.to_string()) {
|
||||||
|
tracing::warn!("failed to write clipboard fallback for selection: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawHandles {
|
||||||
|
window: RawWindowHandle,
|
||||||
|
display: RawDisplayHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasDisplayHandle for RawHandles {
|
||||||
|
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
|
||||||
|
Ok(unsafe { DisplayHandle::borrow_raw(self.display) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasWindowHandle for RawHandles {
|
||||||
|
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
|
||||||
|
Ok(unsafe { WindowHandle::borrow_raw(self.window) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
use graphite_editor::messages::prelude::DocumentId;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
const GRAPHITE_EXTENSION: &str = "graphite";
|
||||||
|
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "svg", "bmp", "gif"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum FileIoResult {
|
||||||
|
Opened { path: PathBuf, content: Vec<u8> },
|
||||||
|
Imported { path: PathBuf, content: Vec<u8> },
|
||||||
|
SavedDocument { document_id: DocumentId, path: PathBuf },
|
||||||
|
ExportComplete,
|
||||||
|
Cancelled,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_open_dialog(sender: Sender<FileIoResult>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut all_exts: Vec<&str> = Vec::with_capacity(IMAGE_EXTENSIONS.len() + 1);
|
||||||
|
all_exts.push(GRAPHITE_EXTENSION);
|
||||||
|
all_exts.extend_from_slice(IMAGE_EXTENSIONS);
|
||||||
|
|
||||||
|
let dialog = rfd::FileDialog::new()
|
||||||
|
.set_title("Open File")
|
||||||
|
.add_filter("Graphite & Images", &all_exts)
|
||||||
|
.add_filter("Graphite Document", &[GRAPHITE_EXTENSION])
|
||||||
|
.add_filter("Images", IMAGE_EXTENSIONS);
|
||||||
|
|
||||||
|
let result = match dialog.pick_file() {
|
||||||
|
Some(path) => match std::fs::read(&path) {
|
||||||
|
Ok(content) => FileIoResult::Opened { path, content },
|
||||||
|
Err(e) => FileIoResult::Failed(format!("Failed to read {}: {e}", path.display())),
|
||||||
|
},
|
||||||
|
None => FileIoResult::Cancelled,
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_import_dialog(sender: Sender<FileIoResult>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let dialog = rfd::FileDialog::new().set_title("Import Image").add_filter("Images", IMAGE_EXTENSIONS);
|
||||||
|
|
||||||
|
let result = match dialog.pick_file() {
|
||||||
|
Some(path) => match std::fs::read(&path) {
|
||||||
|
Ok(content) => FileIoResult::Imported { path, content },
|
||||||
|
Err(e) => FileIoResult::Failed(format!("Failed to read {}: {e}", path.display())),
|
||||||
|
},
|
||||||
|
None => FileIoResult::Cancelled,
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_save_dialog(sender: Sender<FileIoResult>, suggested_name: String, suggested_folder: Option<PathBuf>, content: Vec<u8>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = match resolve_save_path(&suggested_name, suggested_folder.as_deref(), None) {
|
||||||
|
Some(path) => match std::fs::write(&path, &content) {
|
||||||
|
Ok(()) => FileIoResult::ExportComplete,
|
||||||
|
Err(e) => FileIoResult::Failed(format!("Failed to write {}: {e}", path.display())),
|
||||||
|
},
|
||||||
|
None => FileIoResult::Cancelled,
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_save_document(sender: Sender<FileIoResult>, document_id: DocumentId, suggested_name: String, explicit_path: Option<PathBuf>, suggested_folder: Option<PathBuf>, content: Vec<u8>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let chosen = match explicit_path {
|
||||||
|
Some(path) => Some(path),
|
||||||
|
None => resolve_save_path(&suggested_name, suggested_folder.as_deref(), Some(GRAPHITE_EXTENSION)),
|
||||||
|
};
|
||||||
|
let result = match chosen {
|
||||||
|
Some(path) => match std::fs::write(&path, &content) {
|
||||||
|
Ok(()) => FileIoResult::SavedDocument { document_id, path },
|
||||||
|
Err(e) => FileIoResult::Failed(format!("Failed to write {}: {e}", path.display())),
|
||||||
|
},
|
||||||
|
None => FileIoResult::Cancelled,
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_export_image(sender: Sender<FileIoResult>, svg: String, name: String, mime: String, size: (f64, f64)) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let extension = match mime.as_str() {
|
||||||
|
"image/svg+xml" => "svg",
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/jpeg" => "jpg",
|
||||||
|
_ => "bin",
|
||||||
|
};
|
||||||
|
let chosen = resolve_save_path(&name, None, Some(extension));
|
||||||
|
let Some(path) = chosen else {
|
||||||
|
let _ = sender.send(FileIoResult::Cancelled);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = match mime.as_str() {
|
||||||
|
"image/svg+xml" => Ok(svg.into_bytes()),
|
||||||
|
"image/png" => rasterise_svg(&svg, size, false),
|
||||||
|
"image/jpeg" => rasterise_svg(&svg, size, true),
|
||||||
|
other => Err(format!("Unsupported export mime: {other}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match bytes.and_then(|b| std::fs::write(&path, &b).map_err(|e| format!("Failed to write {}: {e}", path.display()))) {
|
||||||
|
Ok(()) => FileIoResult::ExportComplete,
|
||||||
|
Err(message) => FileIoResult::Failed(message),
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_save_path(suggested_name: &str, suggested_folder: Option<&std::path::Path>, force_extension: Option<&str>) -> Option<PathBuf> {
|
||||||
|
let mut dialog = rfd::FileDialog::new().set_title("Save File").set_file_name(suggested_name);
|
||||||
|
if let Some(folder) = suggested_folder {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
if let Some(ext) = force_extension {
|
||||||
|
let label = format!("{} file", ext.to_ascii_uppercase());
|
||||||
|
dialog = dialog.add_filter(label.as_str(), &[ext]);
|
||||||
|
}
|
||||||
|
let mut path = dialog.save_file()?;
|
||||||
|
if let Some(ext) = force_extension {
|
||||||
|
if path.extension().and_then(|e| e.to_str()).map(|e| !e.eq_ignore_ascii_case(ext)).unwrap_or(true) {
|
||||||
|
path.set_extension(ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rasterise_svg(svg: &str, size: (f64, f64), jpeg: bool) -> Result<Vec<u8>, String> {
|
||||||
|
let (width, height) = (size.0.max(1.0) as u32, size.1.max(1.0) as u32);
|
||||||
|
let opts = usvg::Options::default();
|
||||||
|
let tree = usvg::Tree::from_str(svg, &opts).map_err(|e| format!("SVG parse error: {e}"))?;
|
||||||
|
|
||||||
|
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| "Pixmap allocation failed (zero or oversized dimensions)".to_string())?;
|
||||||
|
if jpeg {
|
||||||
|
pixmap.fill(resvg::tiny_skia::Color::WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree_size = tree.size();
|
||||||
|
let scale_x = width as f32 / tree_size.width();
|
||||||
|
let scale_y = height as f32 / tree_size.height();
|
||||||
|
let transform = resvg::tiny_skia::Transform::from_scale(scale_x, scale_y);
|
||||||
|
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||||
|
|
||||||
|
if jpeg {
|
||||||
|
let rgba = pixmap.data();
|
||||||
|
let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec()).ok_or_else(|| "RGBA buffer mismatch".to_string())?;
|
||||||
|
let rgb = image::DynamicImage::ImageRgba8(buffer).to_rgb8();
|
||||||
|
let mut out = Vec::with_capacity((width * height * 3) as usize);
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92);
|
||||||
|
encoder.encode(rgb.as_raw(), width, height, image::ExtendedColorType::Rgb8).map_err(|e| format!("JPEG encode error: {e}"))?;
|
||||||
|
Ok(out)
|
||||||
|
} else {
|
||||||
|
pixmap.encode_png().map_err(|e| format!("PNG encode error: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
use iced_widget::image::Handle;
|
||||||
|
use include_dir::{Dir, include_dir};
|
||||||
|
use resvg::tiny_skia;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
|
static BRANDING: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../branding/assets");
|
||||||
|
|
||||||
|
const RASTER_SCALE: f32 = 2.0;
|
||||||
|
|
||||||
|
static CACHE: LazyLock<Mutex<HashMap<&'static str, Handle>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
pub fn icon_handle(name: &str) -> Option<Handle> {
|
||||||
|
let (key, path, size) = lookup(name)?;
|
||||||
|
|
||||||
|
if let Some(cached) = CACHE.lock().ok().and_then(|c| c.get(key).cloned()) {
|
||||||
|
return Some(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = BRANDING.get_file(path)?.contents();
|
||||||
|
let handle = if path.ends_with(".svg") {
|
||||||
|
rasterize_svg(bytes, size)?
|
||||||
|
} else {
|
||||||
|
decode_raster(bytes)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut cache) = CACHE.lock() {
|
||||||
|
cache.insert(key, handle.clone());
|
||||||
|
}
|
||||||
|
Some(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rasterize_svg(bytes: &[u8], size: Option<u32>) -> Option<Handle> {
|
||||||
|
let opt = usvg::Options::default();
|
||||||
|
let tree = usvg::Tree::from_data(bytes, &opt).ok()?;
|
||||||
|
let svg_size = tree.size();
|
||||||
|
|
||||||
|
let (width, height) = match size {
|
||||||
|
Some(s) => {
|
||||||
|
let s_f = s as f32 * RASTER_SCALE;
|
||||||
|
(s_f.max(1.0).round() as u32, s_f.max(1.0).round() as u32)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let w = (svg_size.width() * RASTER_SCALE).max(1.0).round() as u32;
|
||||||
|
let h = (svg_size.height() * RASTER_SCALE).max(1.0).round() as u32;
|
||||||
|
(w, h)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scale_x = width as f32 / svg_size.width();
|
||||||
|
let scale_y = height as f32 / svg_size.height();
|
||||||
|
let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
|
||||||
|
|
||||||
|
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
|
||||||
|
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||||
|
|
||||||
|
let rgba = pixmap.take_demultiplied();
|
||||||
|
Some(Handle::from_rgba(width, height, rgba))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_raster(bytes: &[u8]) -> Option<Handle> {
|
||||||
|
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
Some(Handle::from_rgba(w, h, img.into_raw()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(name: &str) -> Option<(&'static str, &'static str, Option<u32>)> {
|
||||||
|
for &(n, path, size) in ICONS {
|
||||||
|
if n == name {
|
||||||
|
return Some((n, path, size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: &[(&str, &str, Option<u32>)] = &[
|
||||||
|
("GraphiteLogotypeSolid", "graphics/graphite-logotype-solid.svg", None),
|
||||||
|
("Add", "icon-12px-solid/add.svg", Some(12)),
|
||||||
|
("Checkmark", "icon-12px-solid/checkmark.svg", Some(12)),
|
||||||
|
("Clipped", "icon-12px-solid/clipped.svg", Some(12)),
|
||||||
|
("CloseX", "icon-12px-solid/close-x.svg", Some(12)),
|
||||||
|
("Delay", "icon-12px-solid/delay.svg", Some(12)),
|
||||||
|
("Dot", "icon-12px-solid/dot.svg", Some(12)),
|
||||||
|
("DotThick", "icon-12px-solid/dot-thick.svg", Some(12)),
|
||||||
|
("DropdownArrow", "icon-12px-solid/dropdown-arrow.svg", Some(12)),
|
||||||
|
("Edit12px", "icon-12px-solid/edit-12px.svg", Some(12)),
|
||||||
|
("Empty12px", "icon-12px-solid/empty-12px.svg", Some(12)),
|
||||||
|
("Failure", "icon-12px-solid/failure.svg", Some(12)),
|
||||||
|
("FullscreenEnter", "icon-12px-solid/fullscreen-enter.svg", Some(12)),
|
||||||
|
("FullscreenExit", "icon-12px-solid/fullscreen-exit.svg", Some(12)),
|
||||||
|
("Grid", "icon-12px-solid/grid.svg", Some(12)),
|
||||||
|
("GridDotted", "icon-12px-solid/grid-dotted.svg", Some(12)),
|
||||||
|
("Info", "icon-12px-solid/info.svg", Some(12)),
|
||||||
|
("KeyboardArrowDown", "icon-12px-solid/keyboard-arrow-down.svg", Some(12)),
|
||||||
|
("KeyboardArrowLeft", "icon-12px-solid/keyboard-arrow-left.svg", Some(12)),
|
||||||
|
("KeyboardArrowRight", "icon-12px-solid/keyboard-arrow-right.svg", Some(12)),
|
||||||
|
("KeyboardArrowUp", "icon-12px-solid/keyboard-arrow-up.svg", Some(12)),
|
||||||
|
("KeyboardBackspace", "icon-12px-solid/keyboard-backspace.svg", Some(12)),
|
||||||
|
("KeyboardCommand", "icon-12px-solid/keyboard-command.svg", Some(12)),
|
||||||
|
("KeyboardControl", "icon-12px-solid/keyboard-control.svg", Some(12)),
|
||||||
|
("KeyboardEnter", "icon-12px-solid/keyboard-enter.svg", Some(12)),
|
||||||
|
("KeyboardOption", "icon-12px-solid/keyboard-option.svg", Some(12)),
|
||||||
|
("KeyboardShift", "icon-12px-solid/keyboard-shift.svg", Some(12)),
|
||||||
|
("KeyboardSpace", "icon-12px-solid/keyboard-space.svg", Some(12)),
|
||||||
|
("KeyboardTab", "icon-12px-solid/keyboard-tab.svg", Some(12)),
|
||||||
|
("License12px", "icon-12px-solid/license-12px.svg", Some(12)),
|
||||||
|
("Link", "icon-12px-solid/link.svg", Some(12)),
|
||||||
|
("Overlays", "icon-12px-solid/overlays.svg", Some(12)),
|
||||||
|
("Remove", "icon-12px-solid/remove.svg", Some(12)),
|
||||||
|
("RenderModeNormal", "icon-12px-solid/render-mode-normal.svg", Some(12)),
|
||||||
|
("RenderModeOutline", "icon-12px-solid/render-mode-outline.svg", Some(12)),
|
||||||
|
("RenderModePixels", "icon-12px-solid/render-mode-pixels.svg", Some(12)),
|
||||||
|
("RenderModeSvg", "icon-12px-solid/render-mode-svg.svg", Some(12)),
|
||||||
|
("Snapping", "icon-12px-solid/snapping.svg", Some(12)),
|
||||||
|
("SwapHorizontal", "icon-12px-solid/swap-horizontal.svg", Some(12)),
|
||||||
|
("SwapVertical", "icon-12px-solid/swap-vertical.svg", Some(12)),
|
||||||
|
("VerticalEllipsis", "icon-12px-solid/vertical-ellipsis.svg", Some(12)),
|
||||||
|
("Warning", "icon-12px-solid/warning.svg", Some(12)),
|
||||||
|
("WindowButtonWinClose", "icon-12px-solid/window-button-win-close.svg", Some(12)),
|
||||||
|
("WindowButtonWinMaximize", "icon-12px-solid/window-button-win-maximize.svg", Some(12)),
|
||||||
|
("WindowButtonWinMinimize", "icon-12px-solid/window-button-win-minimize.svg", Some(12)),
|
||||||
|
("WindowButtonWinRestoreDown", "icon-12px-solid/window-button-win-restore-down.svg", Some(12)),
|
||||||
|
("WorkingColors", "icon-12px-solid/working-colors.svg", Some(12)),
|
||||||
|
("AlignBottom", "icon-16px-solid/align-bottom.svg", Some(16)),
|
||||||
|
("AlignHorizontalCenter", "icon-16px-solid/align-horizontal-center.svg", Some(16)),
|
||||||
|
("AlignLeft", "icon-16px-solid/align-left.svg", Some(16)),
|
||||||
|
("AlignRight", "icon-16px-solid/align-right.svg", Some(16)),
|
||||||
|
("AlignTop", "icon-16px-solid/align-top.svg", Some(16)),
|
||||||
|
("AlignVerticalCenter", "icon-16px-solid/align-vertical-center.svg", Some(16)),
|
||||||
|
("Artboard", "icon-16px-solid/artboard.svg", Some(16)),
|
||||||
|
("BooleanDifference", "icon-16px-solid/boolean-difference.svg", Some(16)),
|
||||||
|
("BooleanDivide", "icon-16px-solid/boolean-divide.svg", Some(16)),
|
||||||
|
("BooleanIntersect", "icon-16px-solid/boolean-intersect.svg", Some(16)),
|
||||||
|
("BooleanSubtractBack", "icon-16px-solid/boolean-subtract-back.svg", Some(16)),
|
||||||
|
("BooleanSubtractFront", "icon-16px-solid/boolean-subtract-front.svg", Some(16)),
|
||||||
|
("BooleanUnion", "icon-16px-solid/boolean-union.svg", Some(16)),
|
||||||
|
("Bug", "icon-16px-solid/bug.svg", Some(16)),
|
||||||
|
("CheckboxChecked", "icon-16px-solid/checkbox-checked.svg", Some(16)),
|
||||||
|
("CheckboxUnchecked", "icon-16px-solid/checkbox-unchecked.svg", Some(16)),
|
||||||
|
("Close", "icon-16px-solid/close.svg", Some(16)),
|
||||||
|
("CloseAll", "icon-16px-solid/close-all.svg", Some(16)),
|
||||||
|
("Code", "icon-16px-solid/code.svg", Some(16)),
|
||||||
|
("Copy", "icon-16px-solid/copy.svg", Some(16)),
|
||||||
|
("Credits", "icon-16px-solid/credits.svg", Some(16)),
|
||||||
|
("CustomColor", "icon-16px-solid/custom-color.svg", Some(16)),
|
||||||
|
("Cut", "icon-16px-solid/cut.svg", Some(16)),
|
||||||
|
("DeselectAll", "icon-16px-solid/deselect-all.svg", Some(16)),
|
||||||
|
("Edit", "icon-16px-solid/edit.svg", Some(16)),
|
||||||
|
("Empty", "icon-16px-solid/empty.svg", Some(16)),
|
||||||
|
("ExpandFillStroke", "icon-16px-solid/expand-fill-stroke.svg", Some(16)),
|
||||||
|
("Eyedropper", "icon-16px-solid/eyedropper.svg", Some(16)),
|
||||||
|
("EyeHidden", "icon-16px-solid/eye-hidden.svg", Some(16)),
|
||||||
|
("EyeHide", "icon-16px-solid/eye-hide.svg", Some(16)),
|
||||||
|
("EyeShow", "icon-16px-solid/eye-show.svg", Some(16)),
|
||||||
|
("EyeVisible", "icon-16px-solid/eye-visible.svg", Some(16)),
|
||||||
|
("File", "icon-16px-solid/file.svg", Some(16)),
|
||||||
|
("FileExport", "icon-16px-solid/file-export.svg", Some(16)),
|
||||||
|
("FileImport", "icon-16px-solid/file-import.svg", Some(16)),
|
||||||
|
("FlipHorizontal", "icon-16px-solid/flip-horizontal.svg", Some(16)),
|
||||||
|
("FlipVertical", "icon-16px-solid/flip-vertical.svg", Some(16)),
|
||||||
|
("Folder", "icon-16px-solid/folder.svg", Some(16)),
|
||||||
|
("FolderOpen", "icon-16px-solid/folder-open.svg", Some(16)),
|
||||||
|
("FrameAll", "icon-16px-solid/frame-all.svg", Some(16)),
|
||||||
|
("FrameSelected", "icon-16px-solid/frame-selected.svg", Some(16)),
|
||||||
|
("GraphiteLogo", "icon-16px-solid/graphite-logo.svg", Some(16)),
|
||||||
|
("GraphViewClosed", "icon-16px-solid/graph-view-closed.svg", Some(16)),
|
||||||
|
("GraphViewOpen", "icon-16px-solid/graph-view-open.svg", Some(16)),
|
||||||
|
("HandleVisibilityAll", "icon-16px-solid/handle-visibility-all.svg", Some(16)),
|
||||||
|
("HandleVisibilityFrontier", "icon-16px-solid/handle-visibility-frontier.svg", Some(16)),
|
||||||
|
("HandleVisibilitySelected", "icon-16px-solid/handle-visibility-selected.svg", Some(16)),
|
||||||
|
("Heart", "icon-16px-solid/heart.svg", Some(16)),
|
||||||
|
("HistoryRedo", "icon-16px-solid/history-redo.svg", Some(16)),
|
||||||
|
("HistoryUndo", "icon-16px-solid/history-undo.svg", Some(16)),
|
||||||
|
("IconsGrid", "icon-16px-solid/icons-grid.svg", Some(16)),
|
||||||
|
("Image", "icon-16px-solid/image.svg", Some(16)),
|
||||||
|
("InterpolationBlend", "icon-16px-solid/interpolation-blend.svg", Some(16)),
|
||||||
|
("InterpolationMorph", "icon-16px-solid/interpolation-morph.svg", Some(16)),
|
||||||
|
("Layer", "icon-16px-solid/layer.svg", Some(16)),
|
||||||
|
("License", "icon-16px-solid/license.svg", Some(16)),
|
||||||
|
("NewLayer", "icon-16px-solid/new-layer.svg", Some(16)),
|
||||||
|
("Node", "icon-16px-solid/node.svg", Some(16)),
|
||||||
|
("NodeBlur", "icon-16px-solid/node-blur.svg", Some(16)),
|
||||||
|
("NodeBrushwork", "icon-16px-solid/node-brushwork.svg", Some(16)),
|
||||||
|
("NodeColorCorrection", "icon-16px-solid/node-color-correction.svg", Some(16)),
|
||||||
|
("NodeGradient", "icon-16px-solid/node-gradient.svg", Some(16)),
|
||||||
|
("NodeMagicWand", "icon-16px-solid/node-magic-wand.svg", Some(16)),
|
||||||
|
("NodeMask", "icon-16px-solid/node-mask.svg", Some(16)),
|
||||||
|
("NodeMotionBlur", "icon-16px-solid/node-motion-blur.svg", Some(16)),
|
||||||
|
("NodeNodes", "icon-16px-solid/node-nodes.svg", Some(16)),
|
||||||
|
("NodeOutput", "icon-16px-solid/node-output.svg", Some(16)),
|
||||||
|
("NodeShape", "icon-16px-solid/node-shape.svg", Some(16)),
|
||||||
|
("NodeText", "icon-16px-solid/node-text.svg", Some(16)),
|
||||||
|
("NodeTransform", "icon-16px-solid/node-transform.svg", Some(16)),
|
||||||
|
("PadlockLocked", "icon-16px-solid/padlock-locked.svg", Some(16)),
|
||||||
|
("PadlockUnlocked", "icon-16px-solid/padlock-unlocked.svg", Some(16)),
|
||||||
|
("Paste", "icon-16px-solid/paste.svg", Some(16)),
|
||||||
|
("PinActive", "icon-16px-solid/pin-active.svg", Some(16)),
|
||||||
|
("PinInactive", "icon-16px-solid/pin-inactive.svg", Some(16)),
|
||||||
|
("PlaybackPause", "icon-16px-solid/playback-pause.svg", Some(16)),
|
||||||
|
("PlaybackPlay", "icon-16px-solid/playback-play.svg", Some(16)),
|
||||||
|
("PlaybackToEnd", "icon-16px-solid/playback-to-end.svg", Some(16)),
|
||||||
|
("PlaybackToStart", "icon-16px-solid/playback-to-start.svg", Some(16)),
|
||||||
|
("Random", "icon-16px-solid/random.svg", Some(16)),
|
||||||
|
("Reload", "icon-16px-solid/reload.svg", Some(16)),
|
||||||
|
("Reset", "icon-16px-solid/reset.svg", Some(16)),
|
||||||
|
("Resync", "icon-16px-solid/resync.svg", Some(16)),
|
||||||
|
("Reverse", "icon-16px-solid/reverse.svg", Some(16)),
|
||||||
|
("ReverseRadialGradientToLeft", "icon-16px-solid/reverse-radial-gradient-to-left.svg", Some(16)),
|
||||||
|
("ReverseRadialGradientToRight", "icon-16px-solid/reverse-radial-gradient-to-right.svg", Some(16)),
|
||||||
|
("Save", "icon-16px-solid/save.svg", Some(16)),
|
||||||
|
("SelectAll", "icon-16px-solid/select-all.svg", Some(16)),
|
||||||
|
("SelectParent", "icon-16px-solid/select-parent.svg", Some(16)),
|
||||||
|
("Settings", "icon-16px-solid/settings.svg", Some(16)),
|
||||||
|
("SmallDot", "icon-16px-solid/small-dot.svg", Some(16)),
|
||||||
|
("Stack", "icon-16px-solid/stack.svg", Some(16)),
|
||||||
|
("StackBottom", "icon-16px-solid/stack-bottom.svg", Some(16)),
|
||||||
|
("StackHollow", "icon-16px-solid/stack-hollow.svg", Some(16)),
|
||||||
|
("StackLower", "icon-16px-solid/stack-lower.svg", Some(16)),
|
||||||
|
("StackRaise", "icon-16px-solid/stack-raise.svg", Some(16)),
|
||||||
|
("StackReverse", "icon-16px-solid/stack-reverse.svg", Some(16)),
|
||||||
|
("StrokeAlignCenter", "icon-16px-solid/stroke-align-center.svg", Some(16)),
|
||||||
|
("StrokeAlignInside", "icon-16px-solid/stroke-align-inside.svg", Some(16)),
|
||||||
|
("StrokeAlignOutside", "icon-16px-solid/stroke-align-outside.svg", Some(16)),
|
||||||
|
("StrokeCapButt", "icon-16px-solid/stroke-cap-butt.svg", Some(16)),
|
||||||
|
("StrokeCapRound", "icon-16px-solid/stroke-cap-round.svg", Some(16)),
|
||||||
|
("StrokeCapSquare", "icon-16px-solid/stroke-cap-square.svg", Some(16)),
|
||||||
|
("StrokeJoinBevel", "icon-16px-solid/stroke-join-bevel.svg", Some(16)),
|
||||||
|
("StrokeJoinMiter", "icon-16px-solid/stroke-join-miter.svg", Some(16)),
|
||||||
|
("StrokeJoinRound", "icon-16px-solid/stroke-join-round.svg", Some(16)),
|
||||||
|
("StrokeOrderAbove", "icon-16px-solid/stroke-order-above.svg", Some(16)),
|
||||||
|
("StrokeOrderBelow", "icon-16px-solid/stroke-order-below.svg", Some(16)),
|
||||||
|
("TextAlignCenter", "icon-16px-solid/text-align-center.svg", Some(16)),
|
||||||
|
("TextAlignLeft", "icon-16px-solid/text-align-left.svg", Some(16)),
|
||||||
|
("TextAlignRight", "icon-16px-solid/text-align-right.svg", Some(16)),
|
||||||
|
("TextAlignSpineAway", "icon-16px-solid/text-align-spine-away.svg", Some(16)),
|
||||||
|
("TextAlignSpineTowards", "icon-16px-solid/text-align-spine-towards.svg", Some(16)),
|
||||||
|
("TextJustifyAll", "icon-16px-solid/text-justify-all.svg", Some(16)),
|
||||||
|
("TextJustifyCenter", "icon-16px-solid/text-justify-center.svg", Some(16)),
|
||||||
|
("TextJustifyLeft", "icon-16px-solid/text-justify-left.svg", Some(16)),
|
||||||
|
("TextJustifyRight", "icon-16px-solid/text-justify-right.svg", Some(16)),
|
||||||
|
("Tilt", "icon-16px-solid/tilt.svg", Some(16)),
|
||||||
|
("TiltReset", "icon-16px-solid/tilt-reset.svg", Some(16)),
|
||||||
|
("TransformationGrab", "icon-16px-solid/transformation-grab.svg", Some(16)),
|
||||||
|
("TransformationRotate", "icon-16px-solid/transformation-rotate.svg", Some(16)),
|
||||||
|
("TransformationScale", "icon-16px-solid/transformation-scale.svg", Some(16)),
|
||||||
|
("Trash", "icon-16px-solid/trash.svg", Some(16)),
|
||||||
|
("TurnNegative90", "icon-16px-solid/turn-negative-90.svg", Some(16)),
|
||||||
|
("TurnPositive90", "icon-16px-solid/turn-positive-90.svg", Some(16)),
|
||||||
|
("UserManual", "icon-16px-solid/user-manual.svg", Some(16)),
|
||||||
|
("ViewportDesignMode", "icon-16px-solid/viewport-design-mode.svg", Some(16)),
|
||||||
|
("ViewportGuideMode", "icon-16px-solid/viewport-guide-mode.svg", Some(16)),
|
||||||
|
("ViewportSelectMode", "icon-16px-solid/viewport-select-mode.svg", Some(16)),
|
||||||
|
("Volunteer", "icon-16px-solid/volunteer.svg", Some(16)),
|
||||||
|
("Website", "icon-16px-solid/website.svg", Some(16)),
|
||||||
|
("WorkingColorsPrimary", "icon-16px-solid/working-colors-primary.svg", Some(16)),
|
||||||
|
("WorkingColorsSecondary", "icon-16px-solid/working-colors-secondary.svg", Some(16)),
|
||||||
|
("Zoom1x", "icon-16px-solid/zoom-1x.svg", Some(16)),
|
||||||
|
("Zoom2x", "icon-16px-solid/zoom-2x.svg", Some(16)),
|
||||||
|
("ZoomIn", "icon-16px-solid/zoom-in.svg", Some(16)),
|
||||||
|
("ZoomOut", "icon-16px-solid/zoom-out.svg", Some(16)),
|
||||||
|
("ZoomReset", "icon-16px-solid/zoom-reset.svg", Some(16)),
|
||||||
|
("MouseHintDrag", "icon-16px-two-tone/mouse-hint-drag.svg", Some(16)),
|
||||||
|
("MouseHintLmb", "icon-16px-two-tone/mouse-hint-lmb.svg", Some(16)),
|
||||||
|
("MouseHintLmbDouble", "icon-16px-two-tone/mouse-hint-lmb-double.svg", Some(16)),
|
||||||
|
("MouseHintLmbDrag", "icon-16px-two-tone/mouse-hint-lmb-drag.svg", Some(16)),
|
||||||
|
("MouseHintMmb", "icon-16px-two-tone/mouse-hint-mmb.svg", Some(16)),
|
||||||
|
("MouseHintMmbDrag", "icon-16px-two-tone/mouse-hint-mmb-drag.svg", Some(16)),
|
||||||
|
("MouseHintNone", "icon-16px-two-tone/mouse-hint-none.svg", Some(16)),
|
||||||
|
("MouseHintRmb", "icon-16px-two-tone/mouse-hint-rmb.svg", Some(16)),
|
||||||
|
("MouseHintRmbDouble", "icon-16px-two-tone/mouse-hint-rmb-double.svg", Some(16)),
|
||||||
|
("MouseHintRmbDrag", "icon-16px-two-tone/mouse-hint-rmb-drag.svg", Some(16)),
|
||||||
|
("MouseHintScrollDown", "icon-16px-two-tone/mouse-hint-scroll-down.svg", Some(16)),
|
||||||
|
("MouseHintScrollUp", "icon-16px-two-tone/mouse-hint-scroll-up.svg", Some(16)),
|
||||||
|
("GeneralArtboardTool", "icon-24px-two-tone/general-artboard-tool.svg", Some(24)),
|
||||||
|
("GeneralEyedropperTool", "icon-24px-two-tone/general-eyedropper-tool.svg", Some(24)),
|
||||||
|
("GeneralFillTool", "icon-24px-two-tone/general-fill-tool.svg", Some(24)),
|
||||||
|
("GeneralGradientTool", "icon-24px-two-tone/general-gradient-tool.svg", Some(24)),
|
||||||
|
("GeneralNavigateTool", "icon-24px-two-tone/general-navigate-tool.svg", Some(24)),
|
||||||
|
("GeneralSelectTool", "icon-24px-two-tone/general-select-tool.svg", Some(24)),
|
||||||
|
("RasterBrushTool", "icon-24px-two-tone/raster-brush-tool.svg", Some(24)),
|
||||||
|
("RasterCloneTool", "icon-24px-two-tone/raster-clone-tool.svg", Some(24)),
|
||||||
|
("RasterDetailTool", "icon-24px-two-tone/raster-detail-tool.svg", Some(24)),
|
||||||
|
("RasterHealTool", "icon-24px-two-tone/raster-heal-tool.svg", Some(24)),
|
||||||
|
("RasterPatchTool", "icon-24px-two-tone/raster-patch-tool.svg", Some(24)),
|
||||||
|
("RasterRelightTool", "icon-24px-two-tone/raster-relight-tool.svg", Some(24)),
|
||||||
|
("VectorEllipseTool", "icon-24px-two-tone/vector-ellipse-tool.svg", Some(24)),
|
||||||
|
("VectorFreehandTool", "icon-24px-two-tone/vector-freehand-tool.svg", Some(24)),
|
||||||
|
("VectorLineTool", "icon-24px-two-tone/vector-line-tool.svg", Some(24)),
|
||||||
|
("VectorPathTool", "icon-24px-two-tone/vector-path-tool.svg", Some(24)),
|
||||||
|
("VectorPenTool", "icon-24px-two-tone/vector-pen-tool.svg", Some(24)),
|
||||||
|
("VectorPolygonTool", "icon-24px-two-tone/vector-polygon-tool.svg", Some(24)),
|
||||||
|
("VectorRectangleTool", "icon-24px-two-tone/vector-rectangle-tool.svg", Some(24)),
|
||||||
|
("VectorSplineTool", "icon-24px-two-tone/vector-spline-tool.svg", Some(24)),
|
||||||
|
("VectorTextTool", "icon-24px-two-tone/vector-text-tool.svg", Some(24)),
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
|
||||||
|
use winit::keyboard::{KeyCode, ModifiersState, PhysicalKey};
|
||||||
|
|
||||||
|
pub fn translate_physical_winit(physical: PhysicalKey) -> Key {
|
||||||
|
let PhysicalKey::Code(code) = physical else {
|
||||||
|
return Key::Unidentified;
|
||||||
|
};
|
||||||
|
translate_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translate_modifiers_winit(m: ModifiersState) -> ModifierKeys {
|
||||||
|
let mut out = ModifierKeys::empty();
|
||||||
|
if m.shift_key() {
|
||||||
|
out |= ModifierKeys::SHIFT;
|
||||||
|
}
|
||||||
|
if m.alt_key() {
|
||||||
|
out |= ModifierKeys::ALT;
|
||||||
|
}
|
||||||
|
if m.control_key() {
|
||||||
|
out |= ModifierKeys::CONTROL;
|
||||||
|
}
|
||||||
|
if m.meta_key() {
|
||||||
|
out |= ModifierKeys::META_OR_COMMAND;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn translate_code(code: KeyCode) -> Key {
|
||||||
|
match code {
|
||||||
|
KeyCode::Digit0 | KeyCode::Numpad0 => Key::Digit0,
|
||||||
|
KeyCode::Digit1 | KeyCode::Numpad1 => Key::Digit1,
|
||||||
|
KeyCode::Digit2 | KeyCode::Numpad2 => Key::Digit2,
|
||||||
|
KeyCode::Digit3 | KeyCode::Numpad3 => Key::Digit3,
|
||||||
|
KeyCode::Digit4 | KeyCode::Numpad4 => Key::Digit4,
|
||||||
|
KeyCode::Digit5 | KeyCode::Numpad5 => Key::Digit5,
|
||||||
|
KeyCode::Digit6 | KeyCode::Numpad6 => Key::Digit6,
|
||||||
|
KeyCode::Digit7 | KeyCode::Numpad7 => Key::Digit7,
|
||||||
|
KeyCode::Digit8 | KeyCode::Numpad8 => Key::Digit8,
|
||||||
|
KeyCode::Digit9 | KeyCode::Numpad9 => Key::Digit9,
|
||||||
|
|
||||||
|
KeyCode::KeyA => Key::KeyA,
|
||||||
|
KeyCode::KeyB => Key::KeyB,
|
||||||
|
KeyCode::KeyC => Key::KeyC,
|
||||||
|
KeyCode::KeyD => Key::KeyD,
|
||||||
|
KeyCode::KeyE => Key::KeyE,
|
||||||
|
KeyCode::KeyF => Key::KeyF,
|
||||||
|
KeyCode::KeyG => Key::KeyG,
|
||||||
|
KeyCode::KeyH => Key::KeyH,
|
||||||
|
KeyCode::KeyI => Key::KeyI,
|
||||||
|
KeyCode::KeyJ => Key::KeyJ,
|
||||||
|
KeyCode::KeyK => Key::KeyK,
|
||||||
|
KeyCode::KeyL => Key::KeyL,
|
||||||
|
KeyCode::KeyM => Key::KeyM,
|
||||||
|
KeyCode::KeyN => Key::KeyN,
|
||||||
|
KeyCode::KeyO => Key::KeyO,
|
||||||
|
KeyCode::KeyP => Key::KeyP,
|
||||||
|
KeyCode::KeyQ => Key::KeyQ,
|
||||||
|
KeyCode::KeyR => Key::KeyR,
|
||||||
|
KeyCode::KeyS => Key::KeyS,
|
||||||
|
KeyCode::KeyT => Key::KeyT,
|
||||||
|
KeyCode::KeyU => Key::KeyU,
|
||||||
|
KeyCode::KeyV => Key::KeyV,
|
||||||
|
KeyCode::KeyW => Key::KeyW,
|
||||||
|
KeyCode::KeyX => Key::KeyX,
|
||||||
|
KeyCode::KeyY => Key::KeyY,
|
||||||
|
KeyCode::KeyZ => Key::KeyZ,
|
||||||
|
|
||||||
|
KeyCode::Backquote => Key::Backquote,
|
||||||
|
KeyCode::Backslash => Key::Backslash,
|
||||||
|
KeyCode::BracketLeft => Key::BracketLeft,
|
||||||
|
KeyCode::BracketRight => Key::BracketRight,
|
||||||
|
KeyCode::Comma | KeyCode::NumpadComma => Key::Comma,
|
||||||
|
KeyCode::Equal | KeyCode::NumpadEqual => Key::Equal,
|
||||||
|
KeyCode::Minus | KeyCode::NumpadSubtract => Key::Minus,
|
||||||
|
KeyCode::Period | KeyCode::NumpadDecimal => Key::Period,
|
||||||
|
KeyCode::Quote => Key::Quote,
|
||||||
|
KeyCode::Semicolon => Key::Semicolon,
|
||||||
|
KeyCode::Slash | KeyCode::NumpadDivide => Key::Slash,
|
||||||
|
|
||||||
|
KeyCode::AltLeft | KeyCode::AltRight => Key::Alt,
|
||||||
|
KeyCode::MetaLeft | KeyCode::MetaRight => Key::Meta,
|
||||||
|
KeyCode::ShiftLeft | KeyCode::ShiftRight => Key::Shift,
|
||||||
|
KeyCode::ControlLeft | KeyCode::ControlRight => Key::Control,
|
||||||
|
KeyCode::Backspace => Key::Backspace,
|
||||||
|
KeyCode::CapsLock => Key::CapsLock,
|
||||||
|
KeyCode::ContextMenu => Key::ContextMenu,
|
||||||
|
KeyCode::Enter | KeyCode::NumpadEnter => Key::Enter,
|
||||||
|
KeyCode::Space => Key::Space,
|
||||||
|
KeyCode::Tab => Key::Tab,
|
||||||
|
|
||||||
|
KeyCode::Delete => Key::Delete,
|
||||||
|
KeyCode::End => Key::End,
|
||||||
|
KeyCode::Help => Key::Help,
|
||||||
|
KeyCode::Home => Key::Home,
|
||||||
|
KeyCode::Insert => Key::Insert,
|
||||||
|
KeyCode::PageDown => Key::PageDown,
|
||||||
|
KeyCode::PageUp => Key::PageUp,
|
||||||
|
|
||||||
|
KeyCode::ArrowDown => Key::ArrowDown,
|
||||||
|
KeyCode::ArrowLeft => Key::ArrowLeft,
|
||||||
|
KeyCode::ArrowRight => Key::ArrowRight,
|
||||||
|
KeyCode::ArrowUp => Key::ArrowUp,
|
||||||
|
|
||||||
|
KeyCode::NumLock => Key::NumLock,
|
||||||
|
KeyCode::NumpadAdd => Key::NumpadAdd,
|
||||||
|
KeyCode::NumpadHash => Key::NumpadHash,
|
||||||
|
KeyCode::NumpadMultiply | KeyCode::NumpadStar => Key::NumpadMultiply,
|
||||||
|
KeyCode::NumpadParenLeft => Key::NumpadParenLeft,
|
||||||
|
KeyCode::NumpadParenRight => Key::NumpadParenRight,
|
||||||
|
|
||||||
|
KeyCode::Escape => Key::Escape,
|
||||||
|
KeyCode::F1 => Key::F1,
|
||||||
|
KeyCode::F2 => Key::F2,
|
||||||
|
KeyCode::F3 => Key::F3,
|
||||||
|
KeyCode::F4 => Key::F4,
|
||||||
|
KeyCode::F5 => Key::F5,
|
||||||
|
KeyCode::F6 => Key::F6,
|
||||||
|
KeyCode::F7 => Key::F7,
|
||||||
|
KeyCode::F8 => Key::F8,
|
||||||
|
KeyCode::F9 => Key::F9,
|
||||||
|
KeyCode::F10 => Key::F10,
|
||||||
|
KeyCode::F11 => Key::F11,
|
||||||
|
KeyCode::F12 => Key::F12,
|
||||||
|
KeyCode::F13 => Key::F13,
|
||||||
|
KeyCode::F14 => Key::F14,
|
||||||
|
KeyCode::F15 => Key::F15,
|
||||||
|
KeyCode::F16 => Key::F16,
|
||||||
|
KeyCode::F17 => Key::F17,
|
||||||
|
KeyCode::F18 => Key::F18,
|
||||||
|
KeyCode::F19 => Key::F19,
|
||||||
|
KeyCode::F20 => Key::F20,
|
||||||
|
KeyCode::F21 => Key::F21,
|
||||||
|
KeyCode::F22 => Key::F22,
|
||||||
|
KeyCode::F23 => Key::F23,
|
||||||
|
KeyCode::F24 => Key::F24,
|
||||||
|
KeyCode::Fn => Key::Fn,
|
||||||
|
KeyCode::FnLock => Key::FnLock,
|
||||||
|
KeyCode::PrintScreen => Key::PrintScreen,
|
||||||
|
KeyCode::ScrollLock => Key::ScrollLock,
|
||||||
|
KeyCode::Pause => Key::Pause,
|
||||||
|
|
||||||
|
_ => Key::Unidentified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/// applies the editor's layout diffs. each diff is comprised of a widget_path and a new_value. empty path = swap the whole layout.
|
||||||
|
/// otherwise, descend one step each index. tables descend through [row, col] indexed cells.
|
||||||
|
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LayoutStore {
|
||||||
|
layouts: HashMap<LayoutTarget, Layout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayoutStore {
|
||||||
|
pub fn apply(&mut self, target: LayoutTarget, diff: WidgetDiff) {
|
||||||
|
let layout = self.layouts.entry(target).or_default();
|
||||||
|
apply_diff_layout(layout, &diff.widget_path, diff.new_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, target: LayoutTarget) -> Option<&Layout> {
|
||||||
|
self.layouts.get(&target)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&LayoutTarget, &Layout)> {
|
||||||
|
self.layouts.iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_diff_layout(layout: &mut Layout, path: &[usize], new_value: DiffUpdate) {
|
||||||
|
let Some((first, rest)) = path.split_first() else {
|
||||||
|
if let DiffUpdate::Layout(new) = new_value {
|
||||||
|
*layout = new;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(group) = layout.0.get_mut(*first) {
|
||||||
|
apply_diff_group(group, rest, new_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_diff_group(group: &mut LayoutGroup, path: &[usize], new_value: DiffUpdate) {
|
||||||
|
let Some((first, rest)) = path.split_first() else {
|
||||||
|
if let DiffUpdate::LayoutGroup(new) = new_value {
|
||||||
|
*group = new;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match group {
|
||||||
|
LayoutGroup::Column(WidgetColumn { widgets }) => apply_diff_instance(widgets.get_mut(*first), rest, new_value),
|
||||||
|
LayoutGroup::Row(WidgetRow { widgets }) => apply_diff_instance(widgets.get_mut(*first), rest, new_value),
|
||||||
|
LayoutGroup::Section(WidgetSection { layout, .. }) => {
|
||||||
|
if let Some(child) = layout.0.get_mut(*first) {
|
||||||
|
apply_diff_group(child, rest, new_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayoutGroup::Table(WidgetTable { rows, .. }) => {
|
||||||
|
if let Some(table_row) = rows.get_mut(*first)
|
||||||
|
&& let Some((col, rest_after_col)) = rest.split_first()
|
||||||
|
{
|
||||||
|
apply_diff_instance(table_row.get_mut(*col), rest_after_col, new_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_diff_instance(slot: Option<&mut WidgetInstance>, rest: &[usize], new_value: DiffUpdate) {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Some(slot), DiffUpdate::Widget(new)) = (slot, new_value) {
|
||||||
|
*slot = new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,74 +1,20 @@
|
||||||
use graphite_editor::application::{Editor, Environment, Host, Platform};
|
mod app;
|
||||||
use graphite_editor::messages::prelude::*;
|
mod clipboard;
|
||||||
use iced::widget::{column, container, text};
|
mod file_io;
|
||||||
use iced::{Element, Length, Task, Theme};
|
mod icons;
|
||||||
use rand::Rng;
|
mod input;
|
||||||
|
mod layout;
|
||||||
|
mod persist;
|
||||||
|
mod pointer;
|
||||||
|
mod shell;
|
||||||
|
mod window_control;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
mod viewport;
|
||||||
enum Message {
|
mod viewport_widget;
|
||||||
Init,
|
mod widgets;
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
fn main() {
|
||||||
editor: Editor,
|
|
||||||
last_frontend_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn boot() -> (Self, Task<Message>) {
|
|
||||||
let environment = Environment {
|
|
||||||
platform: Platform::Desktop,
|
|
||||||
host: detect_host(),
|
|
||||||
};
|
|
||||||
let seed = rand::rng().random();
|
|
||||||
let editor = Editor::new(environment, seed);
|
|
||||||
|
|
||||||
(Self { editor, last_frontend_count: 0 }, Task::done(Message::Init))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self) -> String {
|
|
||||||
String::from("Graphite")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, message: Message) -> Task<Message> {
|
|
||||||
match message {
|
|
||||||
Message::Init => {
|
|
||||||
let responses = self.editor.handle_message(PortfolioMessage::Init);
|
|
||||||
self.last_frontend_count = responses.len();
|
|
||||||
tracing::info!(count = responses.len(), "editor responded to PortfolioMessage::Init");
|
|
||||||
for response in &responses {
|
|
||||||
tracing::info!(kind = %response.to_discriminant().local_name(), "frontend message");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Element<'_, Message> {
|
|
||||||
container(column![text("Graphite").size(40), text(format!("editor produced {} frontend messages at boot", self.last_frontend_count)).size(14),].spacing(8))
|
|
||||||
.padding(24)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn theme(&self) -> Theme {
|
|
||||||
Theme::Dark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_host() -> Host {
|
|
||||||
if cfg!(target_os = "macos") {
|
|
||||||
Host::Mac
|
|
||||||
} else if cfg!(target_os = "windows") {
|
|
||||||
Host::Windows
|
|
||||||
} else {
|
|
||||||
Host::Linux
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
|
||||||
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())).init();
|
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())).init();
|
||||||
|
|
||||||
iced::application(App::boot, App::update, App::view).title(App::title).theme(App::theme).run()
|
shell::run();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
use graphite_editor::messages::frontend::utility_types::PersistedState;
|
||||||
|
use graphite_editor::messages::prelude::DocumentId;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const APP_DIRECTORY_NAME: &str = if cfg!(target_os = "linux") { "graphite" } else { "Graphite" };
|
||||||
|
const STATE_FILE_NAME: &str = "state.json";
|
||||||
|
const PREFERENCES_FILE_NAME: &str = "preferences.json";
|
||||||
|
const DOCUMENTS_DIRECTORY_NAME: &str = "documents";
|
||||||
|
const DOCUMENT_FILE_EXTENSION: &str = "graphite";
|
||||||
|
|
||||||
|
fn root_dir() -> Option<PathBuf> {
|
||||||
|
let base = dirs::config_local_dir().or_else(dirs::data_local_dir)?;
|
||||||
|
let path = base.join(APP_DIRECTORY_NAME);
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&path) {
|
||||||
|
tracing::warn!("failed to create graphite config directory at {path:?}: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn documents_dir() -> Option<PathBuf> {
|
||||||
|
let path = root_dir()?.join(DOCUMENTS_DIRECTORY_NAME);
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&path) {
|
||||||
|
tracing::warn!("failed to create documents directory at {path:?}: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path() -> Option<PathBuf> {
|
||||||
|
Some(root_dir()?.join(STATE_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preferences_path() -> Option<PathBuf> {
|
||||||
|
Some(root_dir()?.join(PREFERENCES_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_path(id: DocumentId) -> Option<PathBuf> {
|
||||||
|
Some(documents_dir()?.join(format!("{:x}.{}", id.0, DOCUMENT_FILE_EXTENSION)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_state() -> Option<PersistedState> {
|
||||||
|
let path = state_path()?;
|
||||||
|
let raw = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to read persisted state from {path:?}: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match serde_json::from_str::<PersistedState>(&raw) {
|
||||||
|
Ok(state) => Some(state),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to parse persisted state at {path:?}: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_state(state: &PersistedState) {
|
||||||
|
let Some(path) = state_path() else { return };
|
||||||
|
let serialized = match serde_json::to_string_pretty(state) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to serialize persisted state: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::write(&path, serialized) {
|
||||||
|
tracing::warn!("failed to write persisted state to {path:?}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
garbage_collect_documents(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_document(document_id: DocumentId) -> Option<String> {
|
||||||
|
let path = document_path(document_id)?;
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(content) => Some(content),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to read document {document_id:?} from {path:?}: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_document(document_id: DocumentId, content: &str) {
|
||||||
|
let Some(path) = document_path(document_id) else { return };
|
||||||
|
if let Err(e) = std::fs::write(&path, content) {
|
||||||
|
tracing::warn!("failed to write document {document_id:?} to {path:?}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_document(document_id: DocumentId) {
|
||||||
|
let Some(path) = document_path(document_id) else { return };
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => tracing::warn!("failed to delete document {document_id:?} at {path:?}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_preferences() -> Option<String> {
|
||||||
|
let path = preferences_path()?;
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => Some(raw),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to read preferences from {path:?}: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_preferences(json: &str) {
|
||||||
|
let Some(path) = preferences_path() else { return };
|
||||||
|
if let Err(e) = std::fs::write(&path, json) {
|
||||||
|
tracing::warn!("failed to write preferences to {path:?}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn garbage_collect_documents(state: &PersistedState) {
|
||||||
|
let Some(dir) = documents_dir() else { return };
|
||||||
|
let valid: std::collections::HashSet<PathBuf> = state.documents.iter().filter_map(|doc| document_path(doc.id)).collect();
|
||||||
|
let entries = match std::fs::read_dir(&dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to scan documents directory at {dir:?}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && !valid.contains(&path) {
|
||||||
|
if let Err(e) = std::fs::remove_file(&path) {
|
||||||
|
tracing::warn!("failed to remove orphaned document at {path:?}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use graphite_editor::consts::DOUBLE_CLICK_MILLISECONDS;
|
||||||
|
use graphite_editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
|
||||||
|
use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, MouseKeys, ScrollDelta};
|
||||||
|
|
||||||
|
use winit::event::{ElementState, MouseButton, MouseScrollDelta};
|
||||||
|
|
||||||
|
pub const DEFAULT_TOOLSHELF_WIDTH: f64 = 48.0;
|
||||||
|
pub const DEFAULT_SIDEBAR_WIDTH: f64 = 340.0;
|
||||||
|
pub const DEFAULT_MENUBAR_HEIGHT: f64 = 28.0;
|
||||||
|
pub const DEFAULT_STATUSBAR_HEIGHT: f64 = 24.0;
|
||||||
|
|
||||||
|
const SCROLL_LINE_HEIGHT: f64 = 16.0;
|
||||||
|
const SCROLL_LINE_WIDTH: f64 = 16.0;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ViewportRegion {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub width: f64,
|
||||||
|
pub height: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewportRegion {
|
||||||
|
pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
|
||||||
|
Self { x, y, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_window(window_width: f64, window_height: f64) -> Self {
|
||||||
|
let x = DEFAULT_TOOLSHELF_WIDTH;
|
||||||
|
let y = DEFAULT_MENUBAR_HEIGHT;
|
||||||
|
let width = (window_width - DEFAULT_TOOLSHELF_WIDTH - DEFAULT_SIDEBAR_WIDTH).max(0.0);
|
||||||
|
let height = (window_height - DEFAULT_MENUBAR_HEIGHT - DEFAULT_STATUSBAR_HEIGHT).max(0.0);
|
||||||
|
Self { x, y, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, position_logical: (f64, f64)) -> bool {
|
||||||
|
let (px, py) = position_logical;
|
||||||
|
px >= self.x && py >= self.y && px < self.x + self.width && py < self.y + self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_viewport_local(&self, position_logical: (f64, f64)) -> (f64, f64) {
|
||||||
|
(position_logical.0 - self.x, position_logical.1 - self.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewportRegion {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { x: DEFAULT_TOOLSHELF_WIDTH, y: DEFAULT_MENUBAR_HEIGHT, width: 0.0, height: 0.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PointerHit {
|
||||||
|
Viewport,
|
||||||
|
Chrome,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct EditorPointerState {
|
||||||
|
buttons: u8,
|
||||||
|
last_position_logical: (f64, f64),
|
||||||
|
last_down: Option<LastDown>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct LastDown {
|
||||||
|
button: MouseButton,
|
||||||
|
at: Instant,
|
||||||
|
position_logical: (f64, f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorPointerState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buttons(&self) -> u8 {
|
||||||
|
self.buttons
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mouse_keys(&self) -> MouseKeys {
|
||||||
|
MouseKeys::from_bits_truncate(self.buttons)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_position_logical(&self) -> (f64, f64) {
|
||||||
|
self.last_position_logical
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_move(&mut self, position_logical: (f64, f64)) {
|
||||||
|
self.last_position_logical = position_logical;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_button(&mut self, button: MouseButton, state: ElementState, position_logical: (f64, f64)) -> ButtonTransition {
|
||||||
|
self.last_position_logical = position_logical;
|
||||||
|
let bit = translate_winit_button_to_bit(button);
|
||||||
|
match state {
|
||||||
|
ElementState::Pressed => {
|
||||||
|
self.buttons |= bit;
|
||||||
|
let now = Instant::now();
|
||||||
|
let double = self
|
||||||
|
.last_down
|
||||||
|
.is_some_and(|prev| prev.button == button && now.duration_since(prev.at) <= Duration::from_millis(DOUBLE_CLICK_MILLISECONDS));
|
||||||
|
self.last_down = Some(LastDown { button, at: now, position_logical });
|
||||||
|
if double {
|
||||||
|
ButtonTransition::DownDouble
|
||||||
|
} else {
|
||||||
|
ButtonTransition::Down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElementState::Released => {
|
||||||
|
self.buttons &= !bit;
|
||||||
|
ButtonTransition::Up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classify(&self, position_logical: (f64, f64), region: ViewportRegion) -> PointerHit {
|
||||||
|
if region.contains(position_logical) { PointerHit::Viewport } else { PointerHit::Chrome }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ButtonTransition {
|
||||||
|
Down,
|
||||||
|
DownDouble,
|
||||||
|
Up,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translate_winit_button_to_bit(button: MouseButton) -> u8 {
|
||||||
|
match button {
|
||||||
|
MouseButton::Left => MouseKeys::LEFT.bits(),
|
||||||
|
MouseButton::Right => MouseKeys::RIGHT.bits(),
|
||||||
|
MouseButton::Middle => MouseKeys::MIDDLE.bits(),
|
||||||
|
MouseButton::Back => MouseKeys::BACK.bits(),
|
||||||
|
MouseButton::Forward => MouseKeys::FORWARD.bits(),
|
||||||
|
MouseButton::Other(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_editor_mouse_state(position_logical: (f64, f64), buttons: u8) -> EditorMouseState {
|
||||||
|
EditorMouseState::from_keys_and_editor_position(buttons, position_logical.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_editor_mouse_state_with_scroll(position_logical: (f64, f64), buttons: u8, scroll: ScrollDelta) -> EditorMouseState {
|
||||||
|
let mut state = build_editor_mouse_state(position_logical, buttons);
|
||||||
|
state.scroll_delta = scroll;
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn winit_scroll_to_delta(delta: MouseScrollDelta) -> ScrollDelta {
|
||||||
|
match delta {
|
||||||
|
MouseScrollDelta::LineDelta(x, y) => ScrollDelta::new(f64::from(x) * SCROLL_LINE_WIDTH, f64::from(y) * SCROLL_LINE_HEIGHT, 0.0),
|
||||||
|
MouseScrollDelta::PixelDelta(position) => ScrollDelta::new(position.x, position.y, 0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty_modifiers() -> ModifierKeys {
|
||||||
|
ModifierKeys::empty()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||||
|
use winit::application::ApplicationHandler;
|
||||||
|
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||||
|
use winit::event::{ButtonSource, ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
|
||||||
|
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||||
|
use winit::keyboard::ModifiersState;
|
||||||
|
use winit::window::{Window, WindowAttributes, WindowId};
|
||||||
|
|
||||||
|
use crate::viewport::ViewportHandle;
|
||||||
|
|
||||||
|
const DEFAULT_LOGICAL: (u32, u32) = (1280, 800);
|
||||||
|
const MIN_LOGICAL: (u32, u32) = (640, 480);
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
let event_loop = EventLoop::new().expect("winit: create event loop");
|
||||||
|
event_loop.set_control_flow(ControlFlow::Wait);
|
||||||
|
let mut app = ShellApp::default();
|
||||||
|
if let Err(e) = event_loop.run_app(&mut app) {
|
||||||
|
eprintln!("graphite-iced shell exited with error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ShellApp {
|
||||||
|
window: Option<Box<dyn Window>>,
|
||||||
|
handle: Option<ViewportHandle>,
|
||||||
|
modifiers: ModifiersState,
|
||||||
|
last_cursor: PhysicalPosition<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationHandler for ShellApp {
|
||||||
|
fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) {
|
||||||
|
if self.window.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let attrs = WindowAttributes::default()
|
||||||
|
.with_title("Graphite")
|
||||||
|
.with_surface_size(LogicalSize::new(DEFAULT_LOGICAL.0, DEFAULT_LOGICAL.1))
|
||||||
|
.with_min_surface_size(LogicalSize::new(MIN_LOGICAL.0, MIN_LOGICAL.1));
|
||||||
|
|
||||||
|
let window = event_loop.create_window(attrs).expect("winit: create window");
|
||||||
|
let inner: PhysicalSize<u32> = window.surface_size();
|
||||||
|
let scale = window.scale_factor() as f32;
|
||||||
|
|
||||||
|
let raw_window = window.window_handle().expect("winit: window handle").as_raw();
|
||||||
|
let raw_display = window.display_handle().expect("winit: display handle").as_raw();
|
||||||
|
|
||||||
|
let handle = ViewportHandle::new_from_raw(raw_window, raw_display, (inner.width as f32 / scale).max(1.0), (inner.height as f32 / scale).max(1.0), scale).expect("graphite-iced: failed to build wgpu+iced viewport");
|
||||||
|
|
||||||
|
self.window = Some(window);
|
||||||
|
self.handle = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||||
|
let Some(window) = self.window.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(handle) = self.handle.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let scale = window.scale_factor() as f32;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
WindowEvent::CloseRequested => event_loop.exit(),
|
||||||
|
WindowEvent::SurfaceResized(PhysicalSize { width, height }) => {
|
||||||
|
let w = (width as f32 / scale).max(1.0);
|
||||||
|
let h = (height as f32 / scale).max(1.0);
|
||||||
|
handle.resize_px(w, h, scale);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||||
|
let size = window.surface_size();
|
||||||
|
let s = scale_factor as f32;
|
||||||
|
let w = (size.width as f32 / s).max(1.0);
|
||||||
|
let h = (size.height as f32 / s).max(1.0);
|
||||||
|
handle.resize_px(w, h, s);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
WindowEvent::PointerMoved { position, .. } => {
|
||||||
|
self.last_cursor = position;
|
||||||
|
handle.push_mouse_move(position.x as f32 / scale, position.y as f32 / scale);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
WindowEvent::PointerLeft { .. } => {
|
||||||
|
handle.push_mouse_left();
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
WindowEvent::PointerButton { state, button, position, .. } => {
|
||||||
|
let mouse_button = match button {
|
||||||
|
ButtonSource::Mouse(m) => m,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let code = match mouse_button {
|
||||||
|
MouseButton::Left => 0,
|
||||||
|
MouseButton::Right => 1,
|
||||||
|
MouseButton::Middle => 2,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let pressed = matches!(state, ElementState::Pressed);
|
||||||
|
handle.push_mouse_button(position.x as f32 / scale, position.y as f32 / scale, code, pressed);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
WindowEvent::MouseWheel { delta, .. } => {
|
||||||
|
let (dx, dy) = match delta {
|
||||||
|
MouseScrollDelta::LineDelta(x, y) => (x * 20.0, y * 20.0),
|
||||||
|
MouseScrollDelta::PixelDelta(p) => (p.x as f32, p.y as f32),
|
||||||
|
};
|
||||||
|
handle.push_mouse_scroll(self.last_cursor.x as f32 / scale, self.last_cursor.y as f32 / scale, dx, dy);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
WindowEvent::ModifiersChanged(mods) => {
|
||||||
|
self.modifiers = mods.state();
|
||||||
|
}
|
||||||
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
|
let KeyEvent {
|
||||||
|
physical_key, logical_key, state, text, repeat, ..
|
||||||
|
} = event;
|
||||||
|
let pressed = matches!(state, ElementState::Pressed);
|
||||||
|
let utf8 = text.as_ref().map(|s| s.to_string()).or_else(|| match &logical_key {
|
||||||
|
winit::keyboard::Key::Character(s) => Some(s.to_string()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
handle.push_key_event(physical_key, logical_key, utf8, self.modifiers, pressed, repeat);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
WindowEvent::RedrawRequested => handle.render_frame(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
use graph_craft::application_io::PlatformApplicationIo;
|
||||||
|
use graphite_editor::node_graph_executor;
|
||||||
|
use iced_graphics::{Shell as GShell, Viewport};
|
||||||
|
use iced_runtime::user_interface::{self, UserInterface};
|
||||||
|
use iced_wgpu::Engine;
|
||||||
|
use iced_wgpu::core::renderer::{self, Style};
|
||||||
|
use iced_wgpu::core::time::Instant;
|
||||||
|
use iced_wgpu::core::{Color, Event, Point, Size, Theme, mouse, window};
|
||||||
|
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use winit::keyboard::{Key as WKey, ModifiersState, PhysicalKey};
|
||||||
|
|
||||||
|
use crate::app::{App, Message};
|
||||||
|
use crate::input;
|
||||||
|
|
||||||
|
pub struct ViewportHandle {
|
||||||
|
surface: wgpu::Surface<'static>,
|
||||||
|
device: wgpu::Device,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
queue: wgpu::Queue,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
wgpu_context: wgpu_executor::WgpuContext,
|
||||||
|
format: wgpu::TextureFormat,
|
||||||
|
scale: f32,
|
||||||
|
renderer: iced_wgpu::Renderer,
|
||||||
|
viewport: Viewport,
|
||||||
|
cache: user_interface::Cache,
|
||||||
|
events: Vec<Event>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
needs_redraw: bool,
|
||||||
|
state: App,
|
||||||
|
artwork_texture: Option<Arc<wgpu::Texture>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewportHandle {
|
||||||
|
pub fn new_from_raw(raw_window: RawWindowHandle, raw_display: RawDisplayHandle, width: f32, height: f32, scale: f32) -> Option<Self> {
|
||||||
|
let backends = preferred_backends();
|
||||||
|
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||||
|
backends,
|
||||||
|
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||||
|
});
|
||||||
|
let surface = {
|
||||||
|
let target = wgpu::SurfaceTargetUnsafe::RawHandle {
|
||||||
|
raw_display_handle: Some(raw_display),
|
||||||
|
raw_window_handle: raw_window,
|
||||||
|
};
|
||||||
|
unsafe { instance.create_surface_unsafe(target).ok()? }
|
||||||
|
};
|
||||||
|
|
||||||
|
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||||
|
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||||
|
compatible_surface: Some(&surface),
|
||||||
|
force_fallback_adapter: false,
|
||||||
|
}))
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
|
||||||
|
|
||||||
|
let phys_w = ((width * scale) as u32).max(1);
|
||||||
|
let phys_h = ((height * scale) as u32).max(1);
|
||||||
|
|
||||||
|
let caps = surface.get_capabilities(&adapter);
|
||||||
|
let format = caps.formats.iter().find(|f| f.is_srgb()).copied().unwrap_or(*caps.formats.first()?);
|
||||||
|
let alpha_mode = caps.alpha_modes.first().copied().unwrap_or(wgpu::CompositeAlphaMode::Auto);
|
||||||
|
|
||||||
|
surface.configure(
|
||||||
|
&device,
|
||||||
|
&wgpu::SurfaceConfiguration {
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
format,
|
||||||
|
width: phys_w,
|
||||||
|
height: phys_h,
|
||||||
|
present_mode: wgpu::PresentMode::AutoVsync,
|
||||||
|
alpha_mode,
|
||||||
|
view_formats: vec![],
|
||||||
|
desired_maximum_frame_latency: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let wgpu_context = wgpu_executor::WgpuContext {
|
||||||
|
device: Arc::new(device.clone()),
|
||||||
|
queue: Arc::new(queue.clone()),
|
||||||
|
instance: Arc::new(instance),
|
||||||
|
adapter: Arc::new(adapter.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = Engine::new(&adapter, device.clone(), queue.clone(), format, None, GShell::headless());
|
||||||
|
let renderer = iced_wgpu::Renderer::new(engine, renderer::Settings::default());
|
||||||
|
let viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale);
|
||||||
|
|
||||||
|
let (state, initial) = App::new();
|
||||||
|
|
||||||
|
let application_io = PlatformApplicationIo::new_with_context(wgpu_context.clone());
|
||||||
|
pollster::block_on(node_graph_executor::replace_application_io(application_io));
|
||||||
|
|
||||||
|
let mut handle = Self {
|
||||||
|
surface,
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
wgpu_context,
|
||||||
|
format,
|
||||||
|
scale,
|
||||||
|
renderer,
|
||||||
|
viewport,
|
||||||
|
cache: user_interface::Cache::new(),
|
||||||
|
events: Vec::new(),
|
||||||
|
cursor: mouse::Cursor::Available(Point::new(width / 2.0, height / 2.0)),
|
||||||
|
needs_redraw: true,
|
||||||
|
state,
|
||||||
|
artwork_texture: None,
|
||||||
|
};
|
||||||
|
for msg in initial {
|
||||||
|
handle.state.update(msg);
|
||||||
|
}
|
||||||
|
handle.push_viewport_bounds(width, height);
|
||||||
|
Some(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_viewport_bounds(&mut self, logical_width: f32, logical_height: f32) {
|
||||||
|
const SIDEBAR_LOGICAL_PX: f32 = 340.0;
|
||||||
|
let viewport_width = (logical_width - SIDEBAR_LOGICAL_PX).max(1.0);
|
||||||
|
let viewport_height = logical_height.max(1.0);
|
||||||
|
self.state
|
||||||
|
.set_viewport_bounds(0.0, 0.0, viewport_width as f64, viewport_height as f64, self.scale as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_node_graph(&mut self) {
|
||||||
|
let (has_run, image_texture) = pollster::block_on(node_graph_executor::run_node_graph());
|
||||||
|
if has_run {
|
||||||
|
let dims = image_texture.as_ref().map(|t| {
|
||||||
|
let tex: &wgpu::Texture = t.as_ref();
|
||||||
|
(tex.width(), tex.height())
|
||||||
|
});
|
||||||
|
tracing::info!(?dims, "node graph ran");
|
||||||
|
}
|
||||||
|
if let Some(image_texture) = image_texture {
|
||||||
|
let texture: Arc<wgpu::Texture> = image_texture.into();
|
||||||
|
self.artwork_texture = Some(texture.clone());
|
||||||
|
self.state.set_artwork_texture(Some(texture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize_px(&mut self, width: f32, height: f32, scale: f32) {
|
||||||
|
let phys_w = ((width * scale) as u32).max(1);
|
||||||
|
let phys_h = ((height * scale) as u32).max(1);
|
||||||
|
self.scale = scale;
|
||||||
|
self.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale);
|
||||||
|
self.surface.configure(
|
||||||
|
&self.device,
|
||||||
|
&wgpu::SurfaceConfiguration {
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
format: self.format,
|
||||||
|
width: phys_w,
|
||||||
|
height: phys_h,
|
||||||
|
present_mode: wgpu::PresentMode::AutoVsync,
|
||||||
|
alpha_mode: wgpu::CompositeAlphaMode::Auto,
|
||||||
|
view_formats: vec![],
|
||||||
|
desired_maximum_frame_latency: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.push_viewport_bounds(width, height);
|
||||||
|
self.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_mouse_move(&mut self, x: f32, y: f32) {
|
||||||
|
let p = Point::new(x, y);
|
||||||
|
self.cursor = mouse::Cursor::Available(p);
|
||||||
|
self.events.push(Event::Mouse(mouse::Event::CursorMoved { position: p }));
|
||||||
|
self.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_mouse_left(&mut self) {
|
||||||
|
self.cursor = mouse::Cursor::Unavailable;
|
||||||
|
self.events.push(Event::Mouse(mouse::Event::CursorLeft));
|
||||||
|
self.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_mouse_button(&mut self, x: f32, y: f32, button: u32, pressed: bool) {
|
||||||
|
let p = Point::new(x, y);
|
||||||
|
self.cursor = mouse::Cursor::Available(p);
|
||||||
|
self.events.push(Event::Mouse(mouse::Event::CursorMoved { position: p }));
|
||||||
|
let b = match button {
|
||||||
|
0 => mouse::Button::Left,
|
||||||
|
1 => mouse::Button::Right,
|
||||||
|
2 => mouse::Button::Middle,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let ev = if pressed { mouse::Event::ButtonPressed(b) } else { mouse::Event::ButtonReleased(b) };
|
||||||
|
self.events.push(Event::Mouse(ev));
|
||||||
|
self.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_mouse_scroll(&mut self, x: f32, y: f32, dx: f32, dy: f32) {
|
||||||
|
let p = Point::new(x, y);
|
||||||
|
self.cursor = mouse::Cursor::Available(p);
|
||||||
|
self.events.push(Event::Mouse(mouse::Event::WheelScrolled {
|
||||||
|
delta: mouse::ScrollDelta::Pixels { x: dx, y: dy },
|
||||||
|
}));
|
||||||
|
self.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_key_event(&mut self, physical: PhysicalKey, _logical: WKey, _text: Option<String>, modifiers: ModifiersState, pressed: bool, repeat: bool) {
|
||||||
|
let editor_key = input::translate_physical_winit(physical);
|
||||||
|
let editor_modifiers = input::translate_modifiers_winit(modifiers);
|
||||||
|
let msg = if pressed {
|
||||||
|
Message::KeyDown {
|
||||||
|
key: editor_key,
|
||||||
|
modifiers: editor_modifiers,
|
||||||
|
repeat,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Message::KeyUp {
|
||||||
|
key: editor_key,
|
||||||
|
modifiers: editor_modifiers,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.state.update(msg);
|
||||||
|
self.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_frame(&mut self) {
|
||||||
|
if !self.needs_redraw && self.events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = match self.surface.get_current_texture() {
|
||||||
|
wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
|
||||||
|
wgpu::CurrentSurfaceTexture::Occluded => return,
|
||||||
|
other => {
|
||||||
|
tracing::warn!("surface acquire failed: {other:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let view = frame.texture.create_view(&Default::default());
|
||||||
|
let logical = self.viewport.logical_size();
|
||||||
|
|
||||||
|
self.events.push(Event::Window(window::Event::RedrawRequested(Instant::now())));
|
||||||
|
|
||||||
|
let cache = std::mem::take(&mut self.cache);
|
||||||
|
let mut ui = UserInterface::build(self.state.view(), Size::new(logical.width, logical.height), cache, &mut self.renderer);
|
||||||
|
|
||||||
|
let mut messages: Vec<Message> = Vec::new();
|
||||||
|
let drained: Vec<Event> = self.events.drain(..).collect();
|
||||||
|
let _ = ui.update(&drained, self.cursor, &mut self.renderer, &mut messages);
|
||||||
|
|
||||||
|
let theme = Theme::Dark;
|
||||||
|
let style = Style { text_color: Color::WHITE };
|
||||||
|
|
||||||
|
if messages.is_empty() {
|
||||||
|
ui.draw(&mut self.renderer, &theme, &style, self.cursor);
|
||||||
|
self.cache = ui.into_cache();
|
||||||
|
} else {
|
||||||
|
let cache = ui.into_cache();
|
||||||
|
for msg in messages.drain(..) {
|
||||||
|
self.state.update(msg);
|
||||||
|
}
|
||||||
|
let mut ui = UserInterface::build(self.state.view(), Size::new(logical.width, logical.height), cache, &mut self.renderer);
|
||||||
|
ui.draw(&mut self.renderer, &theme, &style, self.cursor);
|
||||||
|
self.cache = ui.into_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state.drain_async_results();
|
||||||
|
self.poll_node_graph();
|
||||||
|
|
||||||
|
self.renderer.present(Some(Color::from_rgb(0.07, 0.07, 0.07)), self.format, &view, &self.viewport);
|
||||||
|
frame.present();
|
||||||
|
self.needs_redraw = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preferred_backends() -> wgpu::Backends {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
wgpu::Backends::METAL
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
wgpu::Backends::DX12
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
{
|
||||||
|
wgpu::Backends::all()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
use iced_widget::core::{Rectangle, mouse};
|
||||||
|
use iced_widget::shader::{self, Pipeline, Primitive, Program};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const VIEWPORT_SHADER: &str = r#"
|
||||||
|
struct VsOut {
|
||||||
|
@builtin(position) pos: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs(@builtin(vertex_index) idx: u32) -> VsOut {
|
||||||
|
var out: VsOut;
|
||||||
|
let x = f32((idx << 1u) & 2u);
|
||||||
|
let y = f32(idx & 2u);
|
||||||
|
out.uv = vec2<f32>(x, y);
|
||||||
|
out.pos = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var artwork: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var artwork_sampler: sampler;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs(in: VsOut) -> @location(0) vec4<f32> {
|
||||||
|
return textureSample(artwork, artwork_sampler, in.uv);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub struct ViewportProgram {
|
||||||
|
pub texture: Option<Arc<wgpu::Texture>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Message> Program<Message> for ViewportProgram {
|
||||||
|
type State = ();
|
||||||
|
type Primitive = ViewportPrimitive;
|
||||||
|
|
||||||
|
fn draw(&self, _state: &Self::State, _cursor: mouse::Cursor, _bounds: Rectangle) -> Self::Primitive {
|
||||||
|
ViewportPrimitive { texture: self.texture.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ViewportPrimitive {
|
||||||
|
pub texture: Option<Arc<wgpu::Texture>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Primitive for ViewportPrimitive {
|
||||||
|
type Pipeline = ViewportPipeline;
|
||||||
|
|
||||||
|
fn prepare(&self, pipeline: &mut Self::Pipeline, device: &wgpu::Device, _queue: &wgpu::Queue, _bounds: &Rectangle, _viewport: &shader::Viewport) {
|
||||||
|
let Some(texture) = &self.texture else {
|
||||||
|
pipeline.bind_group = None;
|
||||||
|
pipeline.current_texture = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let id = Arc::as_ptr(texture);
|
||||||
|
if pipeline.current_texture == Some(id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("graphite-viewport-bind-group"),
|
||||||
|
layout: &pipeline.bind_group_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&pipeline.sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
pipeline.bind_group = Some(bind_group);
|
||||||
|
pipeline.current_texture = Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, pipeline: &Self::Pipeline, render_pass: &mut wgpu::RenderPass<'_>) -> bool {
|
||||||
|
let Some(bind_group) = pipeline.bind_group.as_ref() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
render_pass.set_pipeline(&pipeline.render_pipeline);
|
||||||
|
render_pass.set_bind_group(0, bind_group, &[]);
|
||||||
|
render_pass.draw(0..3, 0..1);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ViewportPipeline {
|
||||||
|
render_pipeline: wgpu::RenderPipeline,
|
||||||
|
bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
sampler: wgpu::Sampler,
|
||||||
|
bind_group: Option<wgpu::BindGroup>,
|
||||||
|
current_texture: Option<*const wgpu::Texture>,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for ViewportPipeline {}
|
||||||
|
unsafe impl Sync for ViewportPipeline {}
|
||||||
|
|
||||||
|
impl Pipeline for ViewportPipeline {
|
||||||
|
fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("graphite-viewport-shader"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(VIEWPORT_SHADER.into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("graphite-viewport-bgl"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("graphite-viewport-sampler"),
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("graphite-viewport-pipeline-layout"),
|
||||||
|
bind_group_layouts: &[Some(&bind_group_layout)],
|
||||||
|
immediate_size: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("graphite-viewport-pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs"),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
strip_index_format: None,
|
||||||
|
front_face: wgpu::FrontFace::Ccw,
|
||||||
|
cull_mode: None,
|
||||||
|
polygon_mode: wgpu::PolygonMode::Fill,
|
||||||
|
unclipped_depth: false,
|
||||||
|
conservative: false,
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: 1,
|
||||||
|
mask: !0,
|
||||||
|
alpha_to_coverage_enabled: false,
|
||||||
|
},
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
render_pipeline,
|
||||||
|
bind_group_layout,
|
||||||
|
sampler,
|
||||||
|
bind_group: None,
|
||||||
|
current_texture: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
|
||||||
|
use iced_widget::core::{Alignment, Background, Border, Color, Length, Theme};
|
||||||
|
use iced_widget::image::Handle as ImageHandle;
|
||||||
|
use iced_widget::{Space, button, column, container, image, row, text};
|
||||||
|
use include_dir::{Dir, include_dir};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
|
const SURFACE: Color = Color::from_rgba(0.10, 0.10, 0.10, 1.0);
|
||||||
|
const SURFACE_HOVER: Color = Color::from_rgba(0.16, 0.16, 0.16, 1.0);
|
||||||
|
const SURFACE_PRESSED: Color = Color::from_rgba(0.20, 0.20, 0.20, 1.0);
|
||||||
|
const BORDER: Color = Color::from_rgba(0.28, 0.28, 0.28, 1.0);
|
||||||
|
const TEXT_PRIMARY: Color = Color::from_rgba(0.86, 0.86, 0.86, 1.0);
|
||||||
|
|
||||||
|
pub fn graphite_button(_: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let bg = match status {
|
||||||
|
button::Status::Hovered => SURFACE_HOVER,
|
||||||
|
button::Status::Pressed => SURFACE_PRESSED,
|
||||||
|
button::Status::Disabled => Color { a: 0.5, ..SURFACE },
|
||||||
|
button::Status::Active => SURFACE,
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color: TEXT_PRIMARY,
|
||||||
|
border: Border {
|
||||||
|
color: BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 2.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn graphite_flush_button(_: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let bg = match status {
|
||||||
|
button::Status::Hovered => Some(Background::Color(SURFACE_HOVER)),
|
||||||
|
button::Status::Pressed => Some(Background::Color(SURFACE_PRESSED)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: bg,
|
||||||
|
text_color: TEXT_PRIMARY,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::app::{Element, Message};
|
||||||
|
|
||||||
|
static FRONTEND_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../frontend/assets");
|
||||||
|
|
||||||
|
static IMAGE_CACHE: LazyLock<Mutex<HashMap<String, ImageHandle>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
fn lookup_image_handle(name: &str) -> Option<ImageHandle> {
|
||||||
|
let filename = camel_to_kebab_png(name);
|
||||||
|
let mut cache = IMAGE_CACHE.lock().ok()?;
|
||||||
|
if let Some(handle) = cache.get(&filename) {
|
||||||
|
return Some(handle.clone());
|
||||||
|
}
|
||||||
|
let bytes = FRONTEND_ASSETS.get_file(&filename)?.contents();
|
||||||
|
let decoded = match ::image::load_from_memory(bytes) {
|
||||||
|
Ok(img) => img,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(filename = %filename, error = %e, "image decode failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rgba = decoded.to_rgba8();
|
||||||
|
let (w, h) = (rgba.width(), rgba.height());
|
||||||
|
let handle = ImageHandle::from_rgba(w, h, rgba.into_raw());
|
||||||
|
cache.insert(filename, handle.clone());
|
||||||
|
Some(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camel_to_kebab_png(name: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in name.chars().enumerate() {
|
||||||
|
if c.is_ascii_uppercase() && i > 0 {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
out.push(c.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
out.push_str(".png");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_px(value: &str) -> Option<f32> {
|
||||||
|
value.trim_end_matches("px").parse::<f32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_layout(target: LayoutTarget, layout: &Layout) -> Element<'_, Message> {
|
||||||
|
let mut col = column![].spacing(4);
|
||||||
|
for group in &layout.0 {
|
||||||
|
col = col.push(render_group(target, group));
|
||||||
|
}
|
||||||
|
col.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_group(target: LayoutTarget, group: &LayoutGroup) -> Element<'_, Message> {
|
||||||
|
match group {
|
||||||
|
LayoutGroup::Column(WidgetColumn { widgets }) => {
|
||||||
|
let mut col = column![].spacing(4);
|
||||||
|
for instance in widgets {
|
||||||
|
col = col.push(widget_to_element(target, instance.widget_id, &instance.widget));
|
||||||
|
}
|
||||||
|
col.into()
|
||||||
|
}
|
||||||
|
LayoutGroup::Row(WidgetRow { widgets }) => {
|
||||||
|
let mut r = row![].spacing(4).align_y(Alignment::Center).width(Length::Fill);
|
||||||
|
for instance in widgets {
|
||||||
|
r = r.push(widget_to_element(target, instance.widget_id, &instance.widget));
|
||||||
|
}
|
||||||
|
r.into()
|
||||||
|
}
|
||||||
|
LayoutGroup::Section(WidgetSection { name, layout, .. }) => {
|
||||||
|
let mut col = column![text(name.as_str()).size(12)].spacing(4);
|
||||||
|
for child in &layout.0 {
|
||||||
|
col = col.push(render_group(target, child));
|
||||||
|
}
|
||||||
|
container(col).padding(6).into()
|
||||||
|
}
|
||||||
|
LayoutGroup::Table(WidgetTable { rows, .. }) => {
|
||||||
|
let mut col = column![].spacing(2);
|
||||||
|
for table_row in rows {
|
||||||
|
let mut r = row![].spacing(4);
|
||||||
|
for instance in table_row {
|
||||||
|
r = r.push(widget_to_element(target, instance.widget_id, &instance.widget));
|
||||||
|
}
|
||||||
|
col = col.push(r);
|
||||||
|
}
|
||||||
|
col.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// maps every Widget variant to an ICED element. labels appended for all available items, not just implemented ones.
|
||||||
|
fn widget_to_element(target: LayoutTarget, widget_id: WidgetId, widget: &Widget) -> Element<'_, Message> {
|
||||||
|
let click = |value: serde_json::Value| Message::WidgetClicked { layout_target: target, widget_id, value };
|
||||||
|
let json = |w: &dyn erased_json::Erased| w.to_json();
|
||||||
|
|
||||||
|
match widget {
|
||||||
|
Widget::TextLabel(w) => text(w.value.as_str()).size(13).into(),
|
||||||
|
Widget::IconLabel(w) => text(format!("[{}]", w.icon)).size(13).into(),
|
||||||
|
Widget::ShortcutLabel(_) => text("⌘").size(11).into(),
|
||||||
|
Widget::Separator(w) => match w.direction {
|
||||||
|
SeparatorDirection::Horizontal => Space::new().width(Length::Fixed(8.0)).into(),
|
||||||
|
SeparatorDirection::Vertical => Space::new().height(Length::Fixed(8.0)).into(),
|
||||||
|
},
|
||||||
|
Widget::IconButton(w) => button(text(format!("[{}]", w.icon))).on_press(click(json(w))).style(graphite_button).into(),
|
||||||
|
Widget::TextButton(w) => {
|
||||||
|
let mut b = button(text(w.label.as_str()).size(13)).on_press(click(serde_json::Value::Array(Vec::new())));
|
||||||
|
b = if w.flush { b.style(graphite_flush_button) } else { b.style(graphite_button) };
|
||||||
|
if w.min_width > 0 {
|
||||||
|
b = b.width(Length::FillPortion(1));
|
||||||
|
}
|
||||||
|
b.into()
|
||||||
|
}
|
||||||
|
Widget::PopoverButton(w) => {
|
||||||
|
let label = w.icon.as_deref().unwrap_or("▾");
|
||||||
|
button(text(format!("[{label}]"))).on_press(click(json(w))).style(graphite_button).into()
|
||||||
|
}
|
||||||
|
Widget::CheckboxInput(w) => {
|
||||||
|
let mark = if w.checked { "[x]" } else { "[ ]" };
|
||||||
|
text(mark).size(13).into()
|
||||||
|
}
|
||||||
|
Widget::RadioInput(w) => {
|
||||||
|
let mut r = row![].spacing(4);
|
||||||
|
for entry in &w.entries {
|
||||||
|
let label = if !entry.label.is_empty() {
|
||||||
|
entry.label.as_str()
|
||||||
|
} else if let Some(icon) = entry.icon.as_deref() {
|
||||||
|
icon
|
||||||
|
} else {
|
||||||
|
"●"
|
||||||
|
};
|
||||||
|
r = r.push(button(text(label).size(12)).on_press(click(json(w))).style(graphite_button));
|
||||||
|
}
|
||||||
|
r.into()
|
||||||
|
}
|
||||||
|
Widget::DropdownInput(w) => {
|
||||||
|
let current = w
|
||||||
|
.selected_index
|
||||||
|
.and_then(|i| w.entries.iter().flatten().nth(i as usize))
|
||||||
|
.map(|entry| entry.label.as_str())
|
||||||
|
.unwrap_or("▾");
|
||||||
|
button(text(format!("{current} ▾")).size(12)).on_press(click(json(w))).style(graphite_button).into()
|
||||||
|
}
|
||||||
|
Widget::NumberInput(w) => {
|
||||||
|
let body = match w.value {
|
||||||
|
Some(v) => format!("{v}"),
|
||||||
|
None => String::from("—"),
|
||||||
|
};
|
||||||
|
let body = if !w.label.is_empty() { format!("{}: {body}", w.label) } else { body };
|
||||||
|
text(body).size(12).into()
|
||||||
|
}
|
||||||
|
Widget::TextInput(w) => text(w.value.as_str()).size(12).into(),
|
||||||
|
Widget::TextAreaInput(w) => text(w.value.as_str()).size(12).into(),
|
||||||
|
Widget::ColorInput(_) => text("◼ color").size(12).into(),
|
||||||
|
Widget::ColorComparisonInput(_) => text("◼◼").size(12).into(),
|
||||||
|
Widget::ColorPresetsInput(_) => text("◼◼◼").size(12).into(),
|
||||||
|
Widget::SpectrumInput(_) => text("[spectrum]").size(11).into(),
|
||||||
|
Widget::VisualColorPickersInput(_) => text("[picker]").size(11).into(),
|
||||||
|
Widget::WorkingColorsInput(_) => text("◼/◻").size(12).into(),
|
||||||
|
Widget::ReferencePointInput(_) => text("·").size(13).into(),
|
||||||
|
Widget::BreadcrumbTrailButtons(w) => {
|
||||||
|
let mut r = row![].spacing(4);
|
||||||
|
for label in &w.labels {
|
||||||
|
r = r.push(text(label.as_str()).size(12));
|
||||||
|
r = r.push(text("›").size(12));
|
||||||
|
}
|
||||||
|
r.into()
|
||||||
|
}
|
||||||
|
Widget::ParameterExposeButton(w) => button(text("●").size(11)).on_press(click(json(w))).style(graphite_button).into(),
|
||||||
|
Widget::NodeCatalog(_) => text("[node catalog]").size(11).into(),
|
||||||
|
Widget::ImageButton(w) => match lookup_image_handle(&w.image) {
|
||||||
|
Some(handle) => {
|
||||||
|
let mut img = image(handle);
|
||||||
|
if w.width.is_some() {
|
||||||
|
img = img.width(Length::FillPortion(1));
|
||||||
|
} else if let Some(height) = w.height.as_deref().and_then(parse_px) {
|
||||||
|
img = img.height(Length::Fixed(height));
|
||||||
|
}
|
||||||
|
img.into()
|
||||||
|
}
|
||||||
|
None => text(format!("[{}]", w.image)).size(11).into(),
|
||||||
|
},
|
||||||
|
Widget::ImageLabel(w) => match lookup_image_handle(&w.url) {
|
||||||
|
Some(handle) => {
|
||||||
|
let mut img = image(handle);
|
||||||
|
if let Some(width) = w.width.as_deref().and_then(parse_px) {
|
||||||
|
img = img.width(Length::Fixed(width));
|
||||||
|
}
|
||||||
|
if let Some(height) = w.height.as_deref().and_then(parse_px) {
|
||||||
|
img = img.height(Length::Fixed(height));
|
||||||
|
}
|
||||||
|
img.into()
|
||||||
|
}
|
||||||
|
None => text(format!("[{}]", w.url)).size(11).into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod erased_json {
|
||||||
|
pub trait Erased {
|
||||||
|
fn to_json(&self) -> serde_json::Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: serde::Serialize> Erased for T {
|
||||||
|
fn to_json(&self) -> serde_json::Value {
|
||||||
|
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
|
||||||
|
use graphite_editor::messages::frontend::utility_types::MouseCursorIcon;
|
||||||
|
use winit::cursor::CursorIcon;
|
||||||
|
use winit::dpi::{LogicalPosition, Position};
|
||||||
|
use winit::monitor::Fullscreen;
|
||||||
|
use winit::window::{CursorGrabMode, Window};
|
||||||
|
|
||||||
|
pub enum WindowCommand {
|
||||||
|
Minimize,
|
||||||
|
Maximize,
|
||||||
|
Fullscreen,
|
||||||
|
Close,
|
||||||
|
Hide,
|
||||||
|
Focus,
|
||||||
|
StartDrag,
|
||||||
|
SetTitle(String),
|
||||||
|
SetCursorIcon(CursorIcon),
|
||||||
|
SetMouseCursor(MouseCursorIcon),
|
||||||
|
PointerLock,
|
||||||
|
PointerWarp { position: (f64, f64) },
|
||||||
|
PointerUnlock,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WindowCommandSender(pub Sender<WindowCommand>);
|
||||||
|
|
||||||
|
impl Clone for WindowCommandSender {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
WindowCommandSender(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowCommandSender {
|
||||||
|
pub fn send(&self, command: WindowCommand) {
|
||||||
|
if let Err(error) = self.0.send(command) {
|
||||||
|
tracing::warn!("dropped window command: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn channel_pair() -> (WindowCommandSender, std::sync::mpsc::Receiver<WindowCommand>) {
|
||||||
|
let (sender, receiver) = channel();
|
||||||
|
(WindowCommandSender(sender), receiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_command(window: &dyn Window, command: WindowCommand) {
|
||||||
|
match command {
|
||||||
|
WindowCommand::Minimize => window.set_minimized(true),
|
||||||
|
WindowCommand::Maximize => {
|
||||||
|
if window.fullscreen().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.set_maximized(!window.is_maximized());
|
||||||
|
}
|
||||||
|
WindowCommand::Fullscreen => {
|
||||||
|
if window.fullscreen().is_some() {
|
||||||
|
window.set_fullscreen(None);
|
||||||
|
} else {
|
||||||
|
window.set_fullscreen(Some(Fullscreen::Borderless(None)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowCommand::Close => {
|
||||||
|
tracing::info!("window close requested");
|
||||||
|
}
|
||||||
|
WindowCommand::Hide => window.set_visible(false),
|
||||||
|
WindowCommand::Focus => {
|
||||||
|
window.set_minimized(false);
|
||||||
|
window.focus_window();
|
||||||
|
}
|
||||||
|
WindowCommand::StartDrag => {
|
||||||
|
if window.fullscreen().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(error) = window.drag_window() {
|
||||||
|
tracing::debug!("drag_window failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowCommand::SetTitle(title) => window.set_title(&title),
|
||||||
|
WindowCommand::SetCursorIcon(icon) => {
|
||||||
|
window.set_cursor_visible(true);
|
||||||
|
window.set_cursor(icon.into());
|
||||||
|
}
|
||||||
|
WindowCommand::SetMouseCursor(cursor) => apply_mouse_cursor(window, cursor),
|
||||||
|
WindowCommand::PointerLock => {
|
||||||
|
if window.set_cursor_grab(CursorGrabMode::Locked).is_err() {
|
||||||
|
let _ = window.set_cursor_grab(CursorGrabMode::Confined);
|
||||||
|
}
|
||||||
|
window.set_cursor_visible(false);
|
||||||
|
}
|
||||||
|
WindowCommand::PointerWarp { position } => {
|
||||||
|
let pos = Position::Logical(LogicalPosition::new(position.0, position.1));
|
||||||
|
if let Err(error) = window.set_cursor_position(pos) {
|
||||||
|
tracing::debug!("set_cursor_position failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowCommand::PointerUnlock => {
|
||||||
|
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||||
|
window.set_cursor_visible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translate_cursor(cursor: MouseCursorIcon) -> CursorIcon {
|
||||||
|
match cursor {
|
||||||
|
MouseCursorIcon::Default => CursorIcon::Default,
|
||||||
|
MouseCursorIcon::None => CursorIcon::Default,
|
||||||
|
MouseCursorIcon::ZoomIn => CursorIcon::ZoomIn,
|
||||||
|
MouseCursorIcon::ZoomOut => CursorIcon::ZoomOut,
|
||||||
|
MouseCursorIcon::Grabbing => CursorIcon::Grabbing,
|
||||||
|
MouseCursorIcon::Crosshair => CursorIcon::Crosshair,
|
||||||
|
MouseCursorIcon::Text => CursorIcon::Text,
|
||||||
|
MouseCursorIcon::Move => CursorIcon::Move,
|
||||||
|
MouseCursorIcon::NSResize => CursorIcon::NsResize,
|
||||||
|
MouseCursorIcon::EWResize => CursorIcon::EwResize,
|
||||||
|
MouseCursorIcon::NESWResize => CursorIcon::NeswResize,
|
||||||
|
MouseCursorIcon::NWSEResize => CursorIcon::NwseResize,
|
||||||
|
MouseCursorIcon::Rotate => CursorIcon::Alias,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_mouse_cursor(window: &dyn Window, cursor: MouseCursorIcon) {
|
||||||
|
if matches!(cursor, MouseCursorIcon::None) {
|
||||||
|
window.set_cursor_visible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.set_cursor_visible(true);
|
||||||
|
window.set_cursor(translate_cursor(cursor).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_window_title(active_doc_name: Option<&str>) -> String {
|
||||||
|
match active_doc_name {
|
||||||
|
Some(name) if !name.is_empty() => format!("{name} \u{2013} Graphite"),
|
||||||
|
_ => String::from("Graphite"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_into<F: FnMut(WindowCommand)>(receiver: &std::sync::mpsc::Receiver<WindowCommand>, mut apply: F) {
|
||||||
|
while let Ok(command) = receiver.try_recv() {
|
||||||
|
apply(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue