diff --git a/Cargo.lock b/Cargo.lock index 149f5684..a538eae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,7 @@ dependencies = [ "emigui 0.1.0", "emigui_glium 0.1.0", "glium 0.24.0 (registry+https://github.com/rust-lang/crates.io-index)", + "webbrowser 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -827,6 +828,20 @@ dependencies = [ "wasm-bindgen 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "webbrowser" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "widestring 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "widestring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "winapi" version = "0.3.8" @@ -994,6 +1009,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum wayland-scanner 0.21.13 (registry+https://github.com/rust-lang/crates.io-index)" = "bf3828c568714507315ee425a9529edc4a4aa9901409e373e9e0027e7622b79e" "checksum wayland-sys 0.21.13 (registry+https://github.com/rust-lang/crates.io-index)" = "520ab0fd578017a0ee2206623ba9ef4afe5e8f23ca7b42f6acfba2f4e66b1628" "checksum web-sys 0.3.37 (registry+https://github.com/rust-lang/crates.io-index)" = "2d6f51648d8c56c366144378a33290049eafdd784071077f6fe37dae64c1c4cb" +"checksum webbrowser 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "97d468a911faaaeb783693b004e1c62e0063e646b0afae5c146cd144e566e66d" +"checksum widestring 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "effc0e4ff8085673ea7b9b2e3c73f6bd4d118810c9009ed8f1e16bd96c331db6" "checksum winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" "checksum winapi-util 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "fa515c5163a99cc82bab70fd3bfdd36d827be85de63737b40fcef2ce084a436e" diff --git a/docs/example_wasm.js b/docs/example_wasm.js index b8e80a13..7cfe67cc 100644 --- a/docs/example_wasm.js +++ b/docs/example_wasm.js @@ -196,12 +196,20 @@ function _assertClass(instance, klass) { /** * @param {State} state * @param {string} raw_input_json +* @returns {string} */ __exports.run_gui = function(state, raw_input_json) { - _assertClass(state, State); - var ptr0 = passStringToWasm0(raw_input_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - var len0 = WASM_VECTOR_LEN; - wasm.run_gui(state.ptr, ptr0, len0); + try { + _assertClass(state, State); + var ptr0 = passStringToWasm0(raw_input_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + wasm.run_gui(8, state.ptr, ptr0, len0); + var r0 = getInt32Memory0()[8 / 4 + 0]; + var r1 = getInt32Memory0()[8 / 4 + 1]; + return getStringFromWasm0(r0, r1); + } finally { + wasm.__wbindgen_free(r0, r1); + } }; function isLikeNone(x) { diff --git a/docs/example_wasm_bg.wasm b/docs/example_wasm_bg.wasm index 130efd1f..6e75f2f5 100644 Binary files a/docs/example_wasm_bg.wasm and b/docs/example_wasm_bg.wasm differ diff --git a/docs/index.html b/docs/index.html index 45923f5d..f9ad9482 100644 --- a/docs/index.html +++ b/docs/index.html @@ -56,8 +56,27 @@ if (g_wasm_app === null) { g_wasm_app = wasm_bindgen.new_webgl_gui("canvas", pixels_per_point()); } - wasm_bindgen.run_gui(g_wasm_app, JSON.stringify(input)); + + let output = JSON.parse(wasm_bindgen.run_gui(g_wasm_app, JSON.stringify(input))); + // console.log(`output: ${JSON.stringify(output)}`); + document.body.style.cursor = from_emigui_cursor(output.cursor_icon); + if (output.open_url) { + window.open(output.open_url, "_self"); + } } + + function from_emigui_cursor(cursor) { + if (cursor == "no_drop") { return "no-drop"; } + else if (cursor == "not_allowed") { return "not-allowed"; } + else if (cursor == "resize_nw_se") { return "nwse-resize"; } + else if (cursor == "pointing_hand") { return "pointer"; } + // TODO: more + else { + // default, help, pointer, progress, wait, cell, crosshair, text, alias, copy, move, grab, grabbing, + return cursor; + } + } + // ---------------------------------------------------------------------------- var g_mouse_pos = null; var g_mouse_down = false; diff --git a/emigui/README.md b/emigui/README.md index fc99a07e..39cf9b83 100644 --- a/emigui/README.md +++ b/emigui/README.md @@ -19,6 +19,7 @@ This is the core library crate Emigui. It is fully platform independent without * [x] Scroll-wheel input * [x] Drag background to scroll * [ ] Kinetic scrolling +* [x] Add support for clicking links * [ ] Menu bar (File, Edit, etc) * [ ] One-line TextField * [ ] Clipboard copy/paste @@ -28,8 +29,7 @@ This is the core library crate Emigui. It is fully platform independent without ### Web version: * [x] Scroll input -* [ ] Add support for clicking links -* [ ] Change to resize cursor on hover +* [x] Change to resize cursor on hover ### Animations Add extremely quick animations for some things, maybe 2-3 frames. For instance: @@ -44,6 +44,7 @@ Add extremely quick animations for some things, maybe 2-3 frames. For instance: ### Other * [ ] Generalize Layout so we can create grid layouts etc * [ ] Persist UI state in external storage +* [ ] Pixel-perfect rendering (round positions to nearest pixel). * [ ] Build in a profiler which tracks which region in which window takes up CPU. * [ ] Draw as flame graph * [ ] Draw as hotmap diff --git a/emigui/src/color.rs b/emigui/src/color.rs index ec2cccb8..31b70818 100644 --- a/emigui/src/color.rs +++ b/emigui/src/color.rs @@ -36,3 +36,4 @@ pub const BLACK: Color = srgba(0, 0, 0, 255); pub const RED: Color = srgba(255, 0, 0, 255); pub const GREEN: Color = srgba(0, 255, 0, 255); pub const BLUE: Color = srgba(0, 0, 255, 255); +pub const LIGHT_BLUE: Color = srgba(140, 160, 255, 255); diff --git a/emigui/src/context.rs b/emigui/src/context.rs index 7444fa61..10670344 100644 --- a/emigui/src/context.rs +++ b/emigui/src/context.rs @@ -4,18 +4,6 @@ use parking_lot::Mutex; use crate::{layout::align_rect, *}; -#[derive(Clone, Copy)] -pub enum CursorIcon { - Default, - ResizeNorthWestSouthEast, -} - -impl Default for CursorIcon { - fn default() -> Self { - CursorIcon::Default - } -} - /// Contains the input, style and output of all GUI commands. pub struct Context { /// The default style for new regions @@ -25,8 +13,7 @@ pub struct Context { pub(crate) memory: Mutex, pub(crate) graphics: Mutex, - /// Set each frame to what the mouse cursor should look like. - pub cursor_icon: Mutex, + pub output: Mutex, /// Used to debug name clashes of e.g. windows used_ids: Mutex>, @@ -41,7 +28,7 @@ impl Clone for Context { input: self.input, memory: Mutex::new(self.memory.lock().clone()), graphics: Mutex::new(self.graphics.lock().clone()), - cursor_icon: Mutex::new(self.cursor_icon.lock().clone()), + output: Mutex::new(self.output.lock().clone()), used_ids: Mutex::new(self.used_ids.lock().clone()), } } @@ -55,11 +42,16 @@ impl Context { input: Default::default(), memory: Default::default(), graphics: Default::default(), - cursor_icon: Default::default(), + output: Default::default(), used_ids: Default::default(), } } + /// Useful for pixel-perfect rendering + pub fn round_to_pixel(&self, point: f32) -> f32 { + (point * self.input.pixels_per_point).round() / self.input.pixels_per_point + } + pub fn input(&self) -> &GuiInput { &self.input } @@ -73,10 +65,13 @@ impl Context { } // TODO: move - pub fn new_frame(&mut self, gui_input: GuiInput) { + pub fn begin_frame(&mut self, gui_input: GuiInput) { self.used_ids.lock().clear(); self.input = gui_input; - *self.cursor_icon.lock() = CursorIcon::Default; + } + + pub fn end_frame(&self) -> Output { + std::mem::take(&mut self.output.lock()) } pub fn drain_paint_lists(&self) -> Vec<(Rect, PaintCmd)> { diff --git a/emigui/src/emigui.rs b/emigui/src/emigui.rs index 9aee0c70..34287bff 100644 --- a/emigui/src/emigui.rs +++ b/emigui/src/emigui.rs @@ -32,7 +32,7 @@ impl Emigui { self.ctx.fonts.texture() } - pub fn new_frame(&mut self, new_input: RawInput) { + pub fn begin_frame(&mut self, new_input: RawInput) { if !self.last_input.mouse_down || self.last_input.mouse_pos.is_none() { self.ctx.memory.lock().active_id = None; } @@ -42,17 +42,17 @@ impl Emigui { // TODO: avoid this clone let mut new_ctx = (*self.ctx).clone(); - new_ctx.new_frame(gui_input); + new_ctx.begin_frame(gui_input); self.ctx = Arc::new(new_ctx); } - /// A region for the entire screen, behind any windows. - pub fn background_region(&mut self) -> Region { - let rect = Rect::from_min_size(Default::default(), self.ctx.input.screen_size); - Region::new(self.ctx.clone(), Layer::Background, Id::background(), rect) + pub fn end_frame(&mut self) -> (Output, PaintBatches) { + let output = self.ctx.end_frame(); + let paint_batches = self.paint(); + (output, paint_batches) } - pub fn paint(&mut self) -> PaintBatches { + fn paint(&mut self) -> PaintBatches { self.mesher_options.aa_size = 1.0 / self.last_input.pixels_per_point; let paint_commands = self.ctx.drain_paint_lists(); let batches = mesh_paint_commands(&self.mesher_options, &self.ctx.fonts, paint_commands); @@ -65,6 +65,14 @@ impl Emigui { batches } + /// A region for the entire screen, behind any windows. + pub fn background_region(&mut self) -> Region { + let rect = Rect::from_min_size(Default::default(), self.ctx.input.screen_size); + Region::new(self.ctx.clone(), Layer::Background, Id::background(), rect) + } +} + +impl Emigui { pub fn ui(&mut self, region: &mut Region) { region.collapsing("Style", |region| { region.add(Checkbox::new( diff --git a/emigui/src/example_app.rs b/emigui/src/example_app.rs index 1a615ae3..165edf9a 100644 --- a/emigui/src/example_app.rs +++ b/emigui/src/example_app.rs @@ -43,6 +43,11 @@ impl ExampleApp { region.add(label!( "Emigui is an experimental immediate mode GUI written in Rust." )); + + region.horizontal(Align::Min, |region| { + region.add_label("Project home page:"); + region.add_hyperlink("https://github.com/emilk/emigui/"); + }); }); CollapsingHeader::new("Widgets") diff --git a/emigui/src/font.rs b/emigui/src/font.rs index 066fe842..efaa5b49 100644 --- a/emigui/src/font.rs +++ b/emigui/src/font.rs @@ -158,7 +158,7 @@ impl Font { (point * self.pixels_per_point).round() / self.pixels_per_point } - /// In points + /// Height of one line of text. In points pub fn line_spacing(&self) -> f32 { self.scale_in_pixels / self.pixels_per_point } diff --git a/emigui/src/lib.rs b/emigui/src/lib.rs index 1b605a84..a38db2d4 100644 --- a/emigui/src/lib.rs +++ b/emigui/src/lib.rs @@ -31,7 +31,7 @@ pub use { crate::emigui::Emigui, collapsing_header::CollapsingHeader, color::Color, - context::{Context, CursorIcon}, + context::Context, fonts::{FontDefinitions, Fonts, TextStyle}, id::Id, layers::*, diff --git a/emigui/src/region.rs b/emigui/src/region.rs index ccc0c174..af092d67 100644 --- a/emigui/src/region.rs +++ b/emigui/src/region.rs @@ -109,6 +109,10 @@ impl Region { .extend(cmds.drain(..).map(|cmd| (clip_rect, cmd))); } + pub fn round_to_pixel(&self, point: f32) -> f32 { + self.ctx.round_to_pixel(point) + } + /// Options for this region, and any child regions we may spawn. pub fn style(&self) -> &Style { &self.style @@ -183,7 +187,7 @@ impl Region { // draw a grey line on the left to mark the region let line_start = child_rect.min() - indent * 0.5; - let line_start = line_start.round(); + let line_start = line_start.round(); // TODO: round to pixel instead let line_end = pos2(line_start.x, line_start.y + size.y - 8.0); self.add_paint_cmd(PaintCmd::Line { points: vec![line_start, line_end], @@ -272,7 +276,7 @@ impl Region { Rect::from_min_max(pos, pos2(pos.x + column_width, self.desired_rect.bottom())); Region { - id: self.make_child_region_id(&("column", col_idx)), + id: self.make_child_id(&("column", col_idx)), dir: Direction::Vertical, ..self.child_region(child_rect) } @@ -303,6 +307,10 @@ impl Region { self.add(Label::new(text)) } + pub fn add_hyperlink(&mut self, url: impl Into) -> GuiResponse { + self.add(Hyperlink::new(url)) + } + pub fn collapsing(&mut self, text: S, add_contents: F) -> GuiResponse where S: Into, @@ -365,7 +373,7 @@ impl Region { self.id.with(&Id::from_pos(self.cursor)) } - pub fn make_child_region_id(&self, child_id: &H) -> Id { + pub fn make_child_id(&self, child_id: &H) -> Id { self.id.with(child_id) } diff --git a/emigui/src/types.rs b/emigui/src/types.rs index 3d26e809..841d7ea1 100644 --- a/emigui/src/types.rs +++ b/emigui/src/types.rs @@ -84,6 +84,28 @@ impl GuiInput { } } +#[derive(Clone, Default, Serialize)] +pub struct Output { + pub cursor_icon: CursorIcon, + /// If set, open this url. + pub open_url: Option, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CursorIcon { + Default, + /// Pointing hand, used for e.g. web links + PointingHand, + ResizeNwSe, +} + +impl Default for CursorIcon { + fn default() -> Self { + CursorIcon::Default + } +} + // ---------------------------------------------------------------------------- #[derive(Clone, Copy, Debug, Default, Serialize)] diff --git a/emigui/src/widgets.rs b/emigui/src/widgets.rs index 8ba6b6c6..8f3ce342 100644 --- a/emigui/src/widgets.rs +++ b/emigui/src/widgets.rs @@ -59,6 +59,62 @@ impl Widget for Label { // ---------------------------------------------------------------------------- +pub struct Hyperlink { + url: String, + text: String, +} + +impl Hyperlink { + pub fn new(url: impl Into) -> Self { + let url = url.into(); + Self { + text: url.clone(), + url, + } + } +} + +impl Widget for Hyperlink { + fn add_to(self, region: &mut Region) -> GuiResponse { + let color = color::LIGHT_BLUE; + let text_style = TextStyle::Body; + let id = region.make_child_id(&self.url); + let font = ®ion.fonts()[text_style]; + let line_spacing = font.line_spacing(); + // TODO: underline + let (text, text_size) = font.layout_multiline(&self.text, region.available_width()); + let interact = region.reserve_space(text_size, Some(id)); + if interact.hovered { + region.ctx().output.lock().cursor_icon = CursorIcon::PointingHand; + } + if interact.clicked { + region.ctx().output.lock().open_url = Some(self.url.clone()); + } + + if interact.hovered { + // Underline: + for fragment in &text { + let pos = interact.rect.min(); + let y = pos.y + fragment.y_offset + line_spacing; + let y = region.round_to_pixel(y); + let min_x = pos.x + fragment.min_x(); + let max_x = pos.x + fragment.max_x(); + region.add_paint_cmd(PaintCmd::Line { + points: vec![pos2(min_x, y), pos2(max_x, y)], + color, + width: region.style().line_width, + }); + } + } + + region.add_text(interact.rect.min(), text_style, text, Some(color)); + + region.response(interact) + } +} + +// ---------------------------------------------------------------------------- + pub struct Button { text: String, text_color: Option, @@ -416,7 +472,7 @@ impl<'a> Widget for Slider<'a> { let value = self.get_value_f32(); let rect = interact.rect; - let rail_radius = (height / 8.0).round().max(2.0); + let rail_radius = region.round_to_pixel((height / 8.0).max(2.0)); let rail_rect = Rect::from_min_max( pos2(interact.rect.left(), rect.center().y - rail_radius), pos2(interact.rect.right(), rect.center().y + rail_radius), diff --git a/emigui/src/window.rs b/emigui/src/window.rs index f566ade5..a47534d8 100644 --- a/emigui/src/window.rs +++ b/emigui/src/window.rs @@ -139,6 +139,8 @@ impl Window { } }; + state.outer_pos = state.outer_pos.round(); // TODO: round to pixel + let min_inner_size = self.min_size; let max_inner_size = self .max_size @@ -227,16 +229,16 @@ impl Window { state.outer_pos += ctx.input().mouse_move; } + if corner_interact.hovered || corner_interact.active { + ctx.output.lock().cursor_icon = CursorIcon::ResizeNwSe; + } + state = State { outer_pos: state.outer_pos, inner_size: new_inner_size, outer_rect: outer_rect, }; - if corner_interact.hovered || corner_interact.active { - *ctx.cursor_icon.lock() = CursorIcon::ResizeNorthWestSouthEast; - } - if win_interact.active || corner_interact.active || mouse_pressed_on_window(ctx, id) { ctx.memory.lock().move_window_to_top(id); } diff --git a/example_glium/Cargo.toml b/example_glium/Cargo.toml index 99ba60fb..2e55e886 100644 --- a/example_glium/Cargo.toml +++ b/example_glium/Cargo.toml @@ -9,3 +9,4 @@ emigui = { path = "../emigui" } emigui_glium = { path = "../emigui_glium" } glium = "0.24" +webbrowser = "0.5" diff --git a/example_glium/src/main.rs b/example_glium/src/main.rs index aec2aaf7..5ef9b720 100644 --- a/example_glium/src/main.rs +++ b/example_glium/src/main.rs @@ -102,7 +102,7 @@ fn main() { _ => (), }); - emigui.new_frame(raw_input); + emigui.begin_frame(raw_input); let mut region = emigui.background_region(); let mut region = region.centered_column(region.available_width().min(480.0)); region.set_align(Align::Min); @@ -127,13 +127,21 @@ fn main() { emigui.ui(region); }); - painter.paint_batches(&display, emigui.paint(), emigui.texture()); + let (output, paint_batches) = emigui.end_frame(); + painter.paint_batches(&display, paint_batches, emigui.texture()); - let cursor = *emigui.ctx.cursor_icon.lock(); - let cursor = match cursor { + let cursor = match output.cursor_icon { CursorIcon::Default => glutin::MouseCursor::Default, - CursorIcon::ResizeNorthWestSouthEast => glutin::MouseCursor::NwseResize, + CursorIcon::PointingHand => glutin::MouseCursor::Hand, + CursorIcon::ResizeNwSe => glutin::MouseCursor::NwseResize, }; + + if let Some(url) = output.open_url { + if let Err(err) = webbrowser::open(&url) { + eprintln!("Failed to open url: {}", err); // TODO show error in imgui + } + } + display.gl_window().set_cursor(cursor); } } diff --git a/example_wasm/src/lib.rs b/example_wasm/src/lib.rs index c749acbc..ce5a1dc9 100644 --- a/example_wasm/src/lib.rs +++ b/example_wasm/src/lib.rs @@ -38,10 +38,10 @@ impl State { }) } - fn run(&mut self, raw_input: RawInput) -> Result<(), JsValue> { + fn run(&mut self, raw_input: RawInput) -> Result { let everything_start = now_sec(); - self.emigui.new_frame(raw_input); + self.emigui.begin_frame(raw_input); let mut region = self.emigui.background_region(); let mut region = region.centered_column(region.available_width().min(480.0)); @@ -53,6 +53,10 @@ impl State { ); region.add_label("This is not JavaScript. This is Rust, running at 60 FPS. This is the web page, reinvented with game tech."); region.add_label("This is also work in progress, and not ready for production... yet :)"); + region.horizontal(Align::Min, |region| { + region.add_label("Project home page:"); + region.add_hyperlink("https://github.com/emilk/emigui/"); + }); region.add(Separator::new()); region.set_align(Align::Min); @@ -88,20 +92,20 @@ impl State { }); let bg_color = srgba(16, 16, 16, 255); - let batches = self.emigui.paint(); - let result = self.webgl_painter.paint_batches( + let (output, batches) = self.emigui.end_frame(); + self.webgl_painter.paint_batches( bg_color, batches, self.emigui.texture(), raw_input.pixels_per_point, - ); + )?; self.frame_times.push_back(now_sec() - everything_start); while self.frame_times.len() > 30 { self.frame_times.pop_front(); } - result + Ok(output) } } @@ -111,8 +115,9 @@ pub fn new_webgl_gui(canvas_id: &str, pixels_per_point: f32) -> Result Result<(), JsValue> { +pub fn run_gui(state: &mut State, raw_input_json: &str) -> Result { // TODO: nicer interface than JSON let raw_input: RawInput = serde_json::from_str(raw_input_json).unwrap(); - state.run(raw_input) + let output = state.run(raw_input)?; + Ok(serde_json::to_string(&output).unwrap()) }