diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68aa33dfa..5b5bd4827 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,7 +71,7 @@ jobs: toolchain: [stable] include: - os: ubuntu-latest - toolchain: "1.80.1" + toolchain: "1.81.0" variant: MSRV - os: ubuntu-latest toolchain: beta diff --git a/Cargo.toml b/Cargo.toml index 6d53672a0..efe65c2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["gui"] categories = ["gui"] repository = "https://github.com/kas-gui/kas" exclude = ["/examples"] -rust-version = "1.80.1" +rust-version = "1.81.0" [package.metadata.docs.rs] features = ["stable"] diff --git a/README.md b/README.md index a3833b319..ac990dccc 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ KAS GUI [![Crates.io](https://img.shields.io/crates/v/kas.svg)](https://crates.io/crates/kas) [![kas-text](https://img.shields.io/badge/GitHub-kas--text-blueviolet)](https://github.com/kas-gui/kas-text/) [![Docs](https://docs.rs/kas/badge.svg)](https://docs.rs/kas) -![Minimum rustc version](https://img.shields.io/badge/rustc-1.80+-lightgray.svg) KAS is a stateful, pure-Rust GUI toolkit supporting: diff --git a/crates/kas-core/src/action.rs b/crates/kas-core/src/action.rs index 8f58758c6..7f00a5a28 100644 --- a/crates/kas-core/src/action.rs +++ b/crates/kas-core/src/action.rs @@ -75,7 +75,5 @@ bitflags! { const UPDATE = 1 << 17; /// The current window should be closed const CLOSE = 1 << 30; - /// Close all windows and exit - const EXIT = 1 << 31; } } diff --git a/crates/kas-core/src/event/cx/cx_pub.rs b/crates/kas-core/src/event/cx/cx_pub.rs index 4f5c12f9a..b08628f65 100644 --- a/crates/kas-core/src/event/cx/cx_pub.rs +++ b/crates/kas-core/src/event/cx/cx_pub.rs @@ -259,7 +259,7 @@ impl EventState { /// Terminate the GUI #[inline] pub fn exit(&mut self) { - self.action |= Action::EXIT; + self.pending_cmds.push_back((Id::ROOT, Command::Exit)); } /// Notify that an [`Action`] should happen @@ -849,21 +849,12 @@ impl<'a> EventCx<'a> { /// /// Navigation focus will return to whichever widget had focus before /// the popup was open. - pub fn close_window(&mut self, id: WindowId) { - if let Some(index) = - self.popups - .iter() - .enumerate() - .find_map(|(i, p)| if p.0 == id { Some(i) } else { None }) - { - let (wid, popup, onf) = self.popups.remove(index); - self.popup_removed.push((popup.id, wid)); - self.runner.close_window(wid); - - if let Some(id) = onf { - self.set_nav_focus(id, FocusSource::Synthetic); + pub fn close_window(&mut self, mut id: WindowId) { + for (index, p) in self.popups.iter().enumerate() { + if p.0 == id { + id = self.close_popup(index); + break; } - return; } self.runner.close_window(id); diff --git a/crates/kas-core/src/event/cx/mod.rs b/crates/kas-core/src/event/cx/mod.rs index 75be0a276..ef8be83e5 100644 --- a/crates/kas-core/src/event/cx/mod.rs +++ b/crates/kas-core/src/event/cx/mod.rs @@ -183,6 +183,7 @@ type AccessLayer = (bool, HashMap); // for each widget during drawing. Most fields contain only a few values, hence // `SmallVec` is used to keep contents in local memory. pub struct EventState { + pub(crate) window_id: WindowId, config: WindowConfig, platform: Platform, disabled: Vec, @@ -362,6 +363,23 @@ impl EventState { grab } + // Remove popup at index and return its [`WindowId`] + // + // Panics if `index` is out of bounds. + // + // The caller must call `runner.close_window(window_id)`. + #[must_use] + fn close_popup(&mut self, index: usize) -> WindowId { + let (window_id, popup, onf) = self.popups.remove(index); + self.popup_removed.push((popup.id, window_id)); + + if let Some(id) = onf { + self.set_nav_focus(id, FocusSource::Synthetic); + } + + window_id + } + /// Clear all active events on `target` fn clear_events(&mut self, target: &Id) { if let Some(id) = self.sel_focus.as_ref() { @@ -446,9 +464,15 @@ impl<'a> EventCx<'a> { widget.id() ); - let opt_command = self.config.shortcuts().try_match(self.modifiers, &vkey); + let opt_cmd = self.config.shortcuts().try_match(self.modifiers, &vkey); - if let Some(cmd) = opt_command { + if Some(Command::Exit) == opt_cmd { + self.runner.exit(); + return; + } else if Some(Command::Close) == opt_cmd { + self.handle_close(); + return; + } else if let Some(cmd) = opt_cmd { let mut targets = vec![]; let mut send = |_self: &mut Self, id: Id, cmd| -> bool { if !targets.contains(&id) { @@ -524,10 +548,10 @@ impl<'a> EventCx<'a> { } let event = Event::Command(Command::Activate, Some(code)); self.send_event(widget, id, event); - } else if self.config.nav_focus && vkey == Key::Named(NamedKey::Tab) { + } else if self.config.nav_focus && opt_cmd == Some(Command::Tab) { let shift = self.modifiers.shift_key(); self.next_nav_focus_impl(widget.re(), None, shift, FocusSource::Key); - } else if vkey == Key::Named(NamedKey::Escape) { + } else if opt_cmd == Some(Command::Escape) { if let Some(id) = self.popups.last().map(|(id, _, _)| *id) { self.close_window(id); } @@ -627,6 +651,15 @@ impl<'a> EventCx<'a> { } } + fn handle_close(&mut self) { + let mut id = self.window_id; + if !self.popups.is_empty() { + let index = self.popups.len() - 1; + id = self.close_popup(index); + } + self.runner.close_window(id); + } + // Call Widget::_nav_next #[inline] fn nav_next( diff --git a/crates/kas-core/src/event/cx/platform.rs b/crates/kas-core/src/event/cx/platform.rs index 8ce22141a..d1689817f 100644 --- a/crates/kas-core/src/event/cx/platform.rs +++ b/crates/kas-core/src/event/cx/platform.rs @@ -25,8 +25,9 @@ const FAKE_MOUSE_BUTTON: MouseButton = MouseButton::Other(0); impl EventState { /// Construct per-window event state #[inline] - pub(crate) fn new(config: WindowConfig, platform: Platform) -> Self { + pub(crate) fn new(window_id: WindowId, config: WindowConfig, platform: Platform) -> Self { EventState { + window_id, config, platform, disabled: vec![], @@ -78,11 +79,10 @@ impl EventState { pub(crate) fn full_configure( &mut self, sizer: &dyn ThemeSize, - wid: WindowId, win: &mut Window, data: &A, ) { - let id = Id::ROOT.make_child(wid.get().cast()); + let id = Id::ROOT.make_child(self.window_id.get().cast()); log::debug!(target: "kas_core::event", "full_configure of Window{id}"); self.action.remove(Action::RECONFIGURE); @@ -248,8 +248,14 @@ impl EventState { } while let Some((id, cmd)) = cx.pending_cmds.pop_front() { - log::trace!(target: "kas_core::event", "sending pending command {cmd:?} to {id}"); - cx.send_event(win.as_node(data), id, Event::Command(cmd, None)); + if cmd == Command::Exit { + cx.runner.exit(); + } else if cmd == Command::Close { + cx.handle_close(); + } else { + log::trace!(target: "kas_core::event", "sending pending command {cmd:?} to {id}"); + cx.send_event(win.as_node(data), id, Event::Command(cmd, None)); + } } while let Some((id, msg)) = cx.send_queue.pop_front() { @@ -282,6 +288,14 @@ impl EventState { std::mem::take(&mut self.action) } + + /// Window has been closed: clean up state + pub(crate) fn suspended(&mut self, runner: &mut dyn RunnerT) { + while !self.popups.is_empty() { + let id = self.close_popup(self.popups.len() - 1); + runner.close_window(id); + } + } } /// Platform API diff --git a/crates/kas-core/src/root.rs b/crates/kas-core/src/root.rs index d4fd0e725..a98abef47 100644 --- a/crates/kas-core/src/root.rs +++ b/crates/kas-core/src/root.rs @@ -401,21 +401,21 @@ impl Window { /// Each [`crate::Popup`] is assigned a [`WindowId`]; both are passed. pub(crate) fn add_popup( &mut self, - cx: &mut EventCx, + cx: &mut ConfigCx, data: &Data, id: WindowId, popup: kas::PopupDescriptor, ) { let index = self.popups.len(); self.popups.push((id, popup, Offset::ZERO)); - self.resize_popup(&mut cx.config_cx(), data, index); + self.resize_popup(cx, data, index); cx.action(Id::ROOT, Action::REDRAW); } /// Trigger closure of a pop-up /// /// If the given `id` refers to a pop-up, it should be closed. - pub(crate) fn remove_popup(&mut self, cx: &mut EventCx, id: WindowId) { + pub(crate) fn remove_popup(&mut self, cx: &mut ConfigCx, id: WindowId) { for i in 0..self.popups.len() { if id == self.popups[i].0 { self.popups.remove(i); diff --git a/crates/kas-core/src/runner/event_loop.rs b/crates/kas-core/src/runner/event_loop.rs index 77ee81b01..3a2ca7472 100644 --- a/crates/kas-core/src/runner/event_loop.rs +++ b/crates/kas-core/src/runner/event_loop.rs @@ -108,7 +108,7 @@ where for window in self.windows.values_mut() { match window.resume(&mut self.state, el) { Ok(winit_id) => { - self.id_map.insert(winit_id, window.window_id); + self.id_map.insert(winit_id, window.window_id()); } Err(e) => { log::error!("Unable to create window: {}", e); @@ -152,15 +152,15 @@ where fn suspended(&mut self, _: &ActiveEventLoop) { if !self.suspended { - for window in self.windows.values_mut() { - window.suspend(); - } + self.windows + .retain(|_, window| window.suspend(&mut self.state)); + self.state.suspended(); self.suspended = true; } } - fn exiting(&mut self, _: &ActiveEventLoop) { - self.state.on_exit(); + fn exiting(&mut self, el: &ActiveEventLoop) { + self.suspended(el); } } @@ -171,7 +171,7 @@ where pub(super) fn new(mut windows: Vec>>, state: State) -> Self { Loop { suspended: true, - windows: windows.drain(..).map(|w| (w.window_id, w)).collect(), + windows: windows.drain(..).map(|w| (w.window_id(), w)).collect(), popups: Default::default(), id_map: Default::default(), state, @@ -180,6 +180,7 @@ where } fn flush_pending(&mut self, el: &ActiveEventLoop) { + let mut close_all = false; while let Some(pending) = self.state.shared.pending.pop_front() { match pending { Pending::AddPopup(parent_id, id, popup) => { @@ -211,11 +212,11 @@ where win_id = id; } if let Some(window) = self.windows.get_mut(&win_id) { - window.send_close(&mut self.state, target); + window.send_close(target); } } Pending::Action(action) => { - if action.contains(Action::CLOSE | Action::EXIT) { + if action.contains(Action::CLOSE) { self.windows.clear(); self.id_map.clear(); el.set_control_flow(ControlFlow::Poll); @@ -225,32 +226,30 @@ where } } } + Pending::Exit => close_all = true, } } - let mut close_all = false; self.resumes.clear(); self.windows.retain(|window_id, window| { let (action, resume) = window.flush_pending(&mut self.state); if let Some(instant) = resume { self.resumes.push((instant, *window_id)); } - if action.contains(Action::EXIT) { - close_all = true; - true - } else if action.contains(Action::CLOSE) { + + if close_all || action.contains(Action::CLOSE) { + window.suspend(&mut self.state); + + // Call flush_pending again since suspend may queue messages. + // We don't care about the returned Action or resume times since + // the window is being destroyed. + let _ = window.flush_pending(&mut self.state); + self.id_map.retain(|_, v| v != window_id); false } else { true } }); - - if close_all { - for (_, mut window) in self.windows.drain() { - window.suspend(); - } - self.id_map.clear(); - } } } diff --git a/crates/kas-core/src/runner/mod.rs b/crates/kas-core/src/runner/mod.rs index d8733f558..58ad1c0ba 100644 --- a/crates/kas-core/src/runner/mod.rs +++ b/crates/kas-core/src/runner/mod.rs @@ -39,6 +39,9 @@ pub extern crate raw_window_handle; /// across windows). This trait must be implemented by the latter. /// /// When no top-level data is required, use `()` which implements this trait. +/// +/// TODO: should we pass some type of interface to the runner to these methods? +/// We could pass a `&mut dyn RunnerT` easily, but that trait is not public. pub trait AppData: 'static { /// Handle messages /// @@ -46,10 +49,22 @@ pub trait AppData: 'static { /// the widget tree (see [kas::event] module doc), a message is left on the /// stack. Unhandled messages will result in warnings in the log. fn handle_messages(&mut self, messages: &mut MessageStack); + + /// Application is being suspended + /// + /// The application should ensure any important state is saved. + /// + /// This method is called when the application has been suspended or is + /// about to exit (on Android/iOS/Web platforms, the application may resume + /// after this method is called; on other platforms this probably indicates + /// imminent closure). Widget state may still exist, but is not live + /// (widgets will not process events or messages). + fn suspended(&mut self) {} } impl AppData for () { fn handle_messages(&mut self, _: &mut MessageStack) {} + fn suspended(&mut self) {} } #[crate::autoimpl(Debug)] @@ -61,6 +76,7 @@ enum Pending> { AddWindow(WindowId, Box>), CloseWindow(WindowId), Action(kas::Action), + Exit, } #[cfg(winit)] diff --git a/crates/kas-core/src/runner/shared.rs b/crates/kas-core/src/runner/shared.rs index 18fc27aed..dad93c071 100644 --- a/crates/kas-core/src/runner/shared.rs +++ b/crates/kas-core/src/runner/shared.rs @@ -97,7 +97,9 @@ where } } - pub(crate) fn on_exit(&self) { + pub(crate) fn suspended(&mut self) { + self.data.suspended(); + match self.options.write_config(&self.shared.config.borrow()) { Ok(()) => (), Err(error) => warn_about_error("Failed to save config", &error), @@ -152,6 +154,9 @@ pub(crate) trait RunnerT { /// Close a window fn close_window(&mut self, id: WindowId); + /// Exit the application + fn exit(&mut self); + /// Attempt to get clipboard contents /// /// In case of failure, paste actions will simply fail. The implementation @@ -228,6 +233,10 @@ impl> RunnerT for SharedS self.pending.push_back(Pending::CloseWindow(id)); } + fn exit(&mut self) { + self.pending.push_back(Pending::Exit); + } + fn get_clipboard(&mut self) -> Option { #[cfg(feature = "clipboard")] { diff --git a/crates/kas-core/src/runner/window.rs b/crates/kas-core/src/runner/window.rs index 235b071d5..528c27bec 100644 --- a/crates/kas-core/src/runner/window.rs +++ b/crates/kas-core/src/runner/window.rs @@ -48,7 +48,6 @@ struct WindowData> { pub struct Window> { _data: std::marker::PhantomData, pub(super) widget: kas::Window, - pub(super) window_id: WindowId, ev_state: EventState, window: Option>, } @@ -65,12 +64,16 @@ impl> Window { Window { _data: std::marker::PhantomData, widget, - window_id, - ev_state: EventState::new(config, shared.platform), + ev_state: EventState::new(window_id, config, shared.platform), window: None, } } + #[inline] + pub(super) fn window_id(&self) -> WindowId { + self.ev_state.window_id + } + /// Open (resume) a window pub(super) fn resume( &mut self, @@ -86,12 +89,8 @@ impl> Window { let config = self.ev_state.config(); let mut theme_window = state.shared.theme.new_window(config); - self.ev_state.full_configure( - theme_window.size(), - self.window_id, - &mut self.widget, - &state.data, - ); + self.ev_state + .full_configure(theme_window.size(), &mut self.widget, &state.data); let node = self.widget.as_node(&state.data); let sizer = SizeCx::new(theme_window.size()); @@ -191,7 +190,7 @@ impl> Window { surface, frame_count: (Instant::now(), 0), - window_id: self.window_id, + window_id: self.ev_state.window_id, solve_cache, theme_window, next_avail_frame_time: time, @@ -205,9 +204,27 @@ impl> Window { } /// Close (suspend) the window, keeping state (widget) - pub(super) fn suspend(&mut self) { - // TODO: close popups and notify the widget to allow saving data - self.window = None; + /// + /// Returns `true` unless this `Window` should be destoyed. + pub(super) fn suspend(&mut self, state: &mut State) -> bool { + if let Some(ref mut window) = self.window { + self.ev_state.suspended(&mut state.shared); + + let mut messages = MessageStack::new(); + let action = self.ev_state.flush_pending( + &mut state.shared, + window, + &mut messages, + &mut self.widget, + &state.data, + ); + state.handle_messages(&mut messages); + + self.window = None; + !action.contains(Action::CLOSE) + } else { + true + } } /// Handle an event @@ -282,7 +299,7 @@ impl> Window { ); state.handle_messages(&mut messages); - if action.contains(Action::CLOSE | Action::EXIT) { + if action.contains(Action::CLOSE) { return (action, None); } self.handle_action(state, action); @@ -370,29 +387,23 @@ impl> Window { return; }; - let mut messages = MessageStack::new(); - self.ev_state - .with(&mut state.shared, window, &mut messages, |cx| { - self.widget.add_popup(cx, &state.data, id, popup) - }); - state.handle_messages(&mut messages); + let size = window.theme_window.size(); + let mut cx = ConfigCx::new(&size, &mut self.ev_state); + self.widget.add_popup(&mut cx, &state.data, id, popup); } pub(super) fn send_action(&mut self, action: Action) { self.ev_state.action(Id::ROOT, action); } - pub(super) fn send_close(&mut self, state: &mut State, id: WindowId) { - if id == self.window_id { + pub(super) fn send_close(&mut self, id: WindowId) { + if id == self.ev_state.window_id { self.ev_state.action(Id::ROOT, Action::CLOSE); } else if let Some(window) = self.window.as_ref() { let widget = &mut self.widget; - let mut messages = MessageStack::new(); - self.ev_state - .with(&mut state.shared, window, &mut messages, |cx| { - widget.remove_popup(cx, id) - }); - state.handle_messages(&mut messages); + let size = window.theme_window.size(); + let mut cx = ConfigCx::new(&size, &mut self.ev_state); + widget.remove_popup(&mut cx, id); } } } @@ -405,12 +416,8 @@ impl> Window { return; }; - self.ev_state.full_configure( - window.theme_window.size(), - self.window_id, - &mut self.widget, - &state.data, - ); + self.ev_state + .full_configure(window.theme_window.size(), &mut self.widget, &state.data); log::trace!(target: "kas_perf::wgpu::window", "reconfigure: {}µs", time.elapsed().as_micros()); } diff --git a/crates/kas-wgpu/Cargo.toml b/crates/kas-wgpu/Cargo.toml index a8394e641..53681cb57 100644 --- a/crates/kas-wgpu/Cargo.toml +++ b/crates/kas-wgpu/Cargo.toml @@ -47,7 +47,7 @@ path = "../kas-core" version = "0.7.0" [dependencies.wgpu] -version = "23.0.1" +version = "24.0.1" default-features = false features = ["spirv"] diff --git a/crates/kas-wgpu/src/draw/draw_pipe.rs b/crates/kas-wgpu/src/draw/draw_pipe.rs index 825210ce6..c4c5d947f 100644 --- a/crates/kas-wgpu/src/draw/draw_pipe.rs +++ b/crates/kas-wgpu/src/draw/draw_pipe.rs @@ -32,7 +32,7 @@ impl DrawPipe { mut custom: CB, options: &Options, ) -> Result { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { backends: options.backend(), ..Default::default() }); diff --git a/crates/kas-wgpu/src/draw/images.rs b/crates/kas-wgpu/src/draw/images.rs index c13bd27d4..042eedeee 100644 --- a/crates/kas-wgpu/src/draw/images.rs +++ b/crates/kas-wgpu/src/draw/images.rs @@ -35,7 +35,7 @@ impl Image { assert!(!data.is_empty()); assert_eq!(data.len(), 4 * usize::conv(size.0) * usize::conv(size.1)); queue.write_texture( - wgpu::ImageCopyTexture { + wgpu::TexelCopyTextureInfo { texture: atlas_pipe.get_texture(self.atlas), mip_level: 0, origin: wgpu::Origin3d { @@ -46,7 +46,7 @@ impl Image { aspect: wgpu::TextureAspect::All, }, data, - wgpu::ImageDataLayout { + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * size.0), rows_per_image: Some(size.1), diff --git a/crates/kas-wgpu/src/draw/text_pipe.rs b/crates/kas-wgpu/src/draw/text_pipe.rs index 7a848622e..542ea389c 100644 --- a/crates/kas-wgpu/src/draw/text_pipe.rs +++ b/crates/kas-wgpu/src/draw/text_pipe.rs @@ -148,7 +148,7 @@ impl Pipeline { } for (atlas, origin, size, data) in self.prepare.drain(..) { queue.write_texture( - wgpu::ImageCopyTexture { + wgpu::TexelCopyTextureInfo { texture: self.atlas_pipe.get_texture(atlas), mip_level: 0, origin: wgpu::Origin3d { @@ -159,7 +159,7 @@ impl Pipeline { aspect: wgpu::TextureAspect::All, }, &data, - wgpu::ImageDataLayout { + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(size.0), rows_per_image: Some(size.1), diff --git a/crates/kas-widgets/src/label.rs b/crates/kas-widgets/src/label.rs index 1fd860b97..9422a0f62 100644 --- a/crates/kas-widgets/src/label.rs +++ b/crates/kas-widgets/src/label.rs @@ -102,9 +102,6 @@ impl_scope! { } /// Set text in an existing `Label` - /// - /// Note: this must not be called before fonts have been initialised - /// (usually done by the theme when the main loop starts). pub fn set_text(&mut self, cx: &mut EventState, text: T) { self.text.set_text(text); let act = self.text.reprepare_action(); @@ -252,9 +249,6 @@ impl_scope! { } /// Set text in an existing `Label` - /// - /// Note: this must not be called before fonts have been initialised - /// (usually done by the theme when the main loop starts). pub fn set_text(&mut self, cx: &mut EventState, text: AccessString) { self.text.set_text(text); let act = self.text.reprepare_action(); diff --git a/crates/kas-widgets/src/text.rs b/crates/kas-widgets/src/text.rs index 7495f4cd3..6e06d3097 100644 --- a/crates/kas-widgets/src/text.rs +++ b/crates/kas-widgets/src/text.rs @@ -140,14 +140,6 @@ impl_scope! { } } -/* TODO(specialization): can we support this? min_specialization is not enough. -impl + FormattableText + 'static> From for Text { - default fn from(text: U) -> Self { - let text = T::from(text); - Text::new(text) - } -}*/ - /// A [`Text`] widget which formats a value from input /// /// Examples: