wgui: windowing: close_if_clicked_outside support, context menus

This commit is contained in:
Aleksander
2026-01-06 00:06:06 +01:00
parent 1d78da16ab
commit a196dd9b3a
13 changed files with 541 additions and 223 deletions

View File

@@ -19,7 +19,7 @@ use crate::{
util::WLength,
},
};
use glam::{Mat4, Vec3};
use glam::{Mat4, Vec2, Vec3};
use std::{
cell::RefCell,
rc::Rc,
@@ -66,7 +66,9 @@ impl Default for Params<'_> {
}
}
pub struct ButtonClickEvent {}
pub struct ButtonClickEvent {
pub mouse_pos_absolute: Option<Vec2>,
}
pub type ButtonClickCallback = Box<dyn Fn(&mut CallbackDataCommon, ButtonClickEvent) -> anyhow::Result<()>>;
pub struct Colors {
@@ -370,9 +372,7 @@ fn register_event_mouse_release(
if state.down {
state.down = false;
if state.hovered
&& let Some(on_click) = &state.on_click
{
if state.hovered {
anim_hover(
rect,
event_data.widget_data,
@@ -383,8 +383,16 @@ fn register_event_mouse_release(
state.sticky_down,
);
on_click(common, ButtonClickEvent {})?;
if let Some(on_click) = &state.on_click {
on_click(
common,
ButtonClickEvent {
mouse_pos_absolute: event_data.metadata.get_mouse_pos_absolute(),
},
)?;
}
}
Ok(EventResult::Consumed)
} else {
Ok(EventResult::Pass)

View File

@@ -1,7 +1,5 @@
use std::{cell::RefCell, rc::Rc};
use taffy::Style;
use crate::{
components::{Component, ComponentBase, ComponentTrait, RefreshData, checkbox::ComponentCheckbox},
event::CallbackDataCommon,

View File

@@ -102,7 +102,7 @@ pub fn parse_component_button<'a>(
}
}
let (widget, component) = button::construct(
let (widget, button) = button::construct(
&mut ctx.get_construct_essentials(parent_id),
button::Params {
color,
@@ -124,7 +124,7 @@ pub fn parse_component_button<'a>(
},
)?;
process_component(ctx, Component(component), widget.id, attribs);
process_component(ctx, Component(button), widget.id, attribs);
parse_children(file, ctx, node, widget.id)?;
Ok(widget.id)

View File

@@ -809,7 +809,7 @@ fn parse_tag_macro(file: &ParserFile, ctx: &mut ParserContext, node: roxmltree::
}
let Some(name) = macro_name else {
log::error!("Template name not specified, ignoring");
log::error!("Macro name not specified, ignoring");
return;
};
@@ -1160,7 +1160,6 @@ fn parse_document_root(
.ok_or_else(|| anyhow::anyhow!("layout node not found"))?;
for child_node in node_layout.children() {
#[allow(clippy::single_match)]
match child_node.tag_name().name() {
/* topmost include directly in <layout> */
"include" => parse_tag_include(file, ctx, parent_id, &raw_attribs(&child_node))?,

View File

@@ -1,200 +0,0 @@
use std::{cell::RefCell, rc::Rc};
use glam::Vec2;
use taffy::prelude::{length, percent};
use crate::{
assets::AssetPath,
components::button::ComponentButton,
event::StyleSetRequest,
globals::WguiGlobals,
i18n::Translation,
layout::{Layout, LayoutTask, LayoutTasks, WidgetPair},
parser::{self, Fetchable, ParserState},
widget::{div::WidgetDiv, label::WidgetLabel},
};
struct OpenedWindow {
layout_tasks: LayoutTasks,
widget: WidgetPair,
content: WidgetPair,
#[allow(dead_code)]
state: ParserState,
}
impl Drop for OpenedWindow {
fn drop(&mut self) {
self.layout_tasks.push(LayoutTask::RemoveWidget(self.widget.id));
}
}
struct State {
opened_window: Option<OpenedWindow>,
}
#[derive(Clone)]
pub struct WguiWindow(Rc<RefCell<State>>);
pub struct OnContentData {
pub widget: WidgetPair,
}
#[derive(Default)]
pub enum WguiWindowPlacement {
#[default]
TopLeft,
BottomLeft,
TopRight,
BottomRight,
}
#[derive(Default)]
pub struct WguiWindowParamsExtra {
pub fixed_width: Option<f32>,
pub fixed_height: Option<f32>,
pub placement: WguiWindowPlacement,
}
pub struct WguiWindowParams<'a> {
pub position: Vec2,
pub globals: WguiGlobals,
pub layout: &'a mut Layout,
pub title: Translation,
pub extra: WguiWindowParamsExtra,
}
impl Default for WguiWindow {
fn default() -> Self {
Self(Rc::new(RefCell::new(State { opened_window: None })))
}
}
const WINDOW_DECORATION_HEADER_HEIGHT: f32 = 32.0;
const WINDOW_DECORATION_PADDING: f32 = 2.0;
impl WguiWindow {
pub fn close(&self) {
self.0.borrow_mut().opened_window = None;
}
pub fn open(&mut self, params: &mut WguiWindowParams) -> anyhow::Result<()> {
// close previous one if it's already open
self.close();
const XML_PATH: AssetPath = AssetPath::WguiInternal("wgui/window_frame.xml");
let (padding, justify_content, align_items) = match params.extra.placement {
WguiWindowPlacement::TopLeft => (
taffy::Rect {
left: length(params.position.x - WINDOW_DECORATION_PADDING),
top: length(params.position.y - WINDOW_DECORATION_HEADER_HEIGHT - WINDOW_DECORATION_PADDING),
bottom: length(0.0),
right: length(0.0),
},
taffy::JustifyContent::Start, // x start
taffy::AlignItems::Start, // y start
),
WguiWindowPlacement::BottomLeft => (
taffy::Rect {
left: length(params.position.x - WINDOW_DECORATION_PADDING),
top: length(0.0),
bottom: length(params.position.y - WINDOW_DECORATION_PADDING),
right: length(0.0),
},
taffy::JustifyContent::Start, // x start
taffy::AlignItems::End, // y end
),
WguiWindowPlacement::TopRight => (
taffy::Rect {
left: length(0.0),
top: length(params.position.y - WINDOW_DECORATION_HEADER_HEIGHT - WINDOW_DECORATION_PADDING),
bottom: length(0.0),
right: length(params.position.x - WINDOW_DECORATION_PADDING),
},
taffy::JustifyContent::End, // x end
taffy::AlignItems::Start, // y start
),
WguiWindowPlacement::BottomRight => (
taffy::Rect {
left: length(0.0),
top: length(0.0),
bottom: length(params.position.y - WINDOW_DECORATION_PADDING),
right: length(params.position.x - WINDOW_DECORATION_PADDING),
},
taffy::JustifyContent::End, // x end
taffy::AlignItems::End, // y end
),
};
let (widget, _) = params.layout.add_topmost_child(
WidgetDiv::create(),
taffy::Style {
position: taffy::Position::Absolute,
align_items: Some(align_items),
justify_content: Some(justify_content),
padding,
size: taffy::Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
},
)?;
let state = parser::parse_from_assets(
&parser::ParseDocumentParams {
globals: params.globals.clone(),
path: XML_PATH,
extra: Default::default(),
},
params.layout,
widget.id,
)?;
let but_close = state.fetch_component_as::<ComponentButton>("but_close").unwrap();
but_close.on_click({
let this = self.clone();
Box::new(move |_common, _e| {
this.close();
Ok(())
})
});
{
let mut text_title = state.fetch_widget_as::<WidgetLabel>(&params.layout.state, "text_window_title")?;
text_title.set_text_simple(&mut params.globals.get(), params.title.clone());
}
let content = state.fetch_widget(&params.layout.state, "content")?;
self.0.borrow_mut().opened_window = Some(OpenedWindow {
widget,
state,
layout_tasks: params.layout.tasks.clone(),
content: content.clone(),
});
let mut c = params.layout.start_common();
if let Some(width) = params.extra.fixed_width {
c.common()
.alterables
.set_style(content.id, StyleSetRequest::Width(length(width)));
}
if let Some(height) = params.extra.fixed_height {
c.common()
.alterables
.set_style(content.id, StyleSetRequest::Height(length(height)));
}
c.finish()?;
Ok(())
}
pub fn get_content(&self) -> WidgetPair {
let state = self.0.borrow_mut();
state.opened_window.as_ref().unwrap().content.clone()
}
}

View File

@@ -0,0 +1,114 @@
use std::{collections::HashMap, rc::Rc};
use glam::Vec2;
use crate::{
assets::AssetPath,
components::button::ComponentButton,
event::CallbackDataCommon,
globals::WguiGlobals,
i18n::Translation,
layout::Layout,
parser::{self, Fetchable},
windowing::window::{WguiWindow, WguiWindowParams, WguiWindowParamsExtra},
};
pub struct Cell {
pub title: Translation,
pub action_name: Rc<str>,
}
pub struct ContextMenuAction<'a> {
pub common: &'a mut CallbackDataCommon<'a>,
pub name: Rc<str>, // action name
}
pub struct OpenParams<'a> {
pub position: Vec2,
pub globals: &'a WguiGlobals,
pub layout: &'a mut Layout,
pub on_action: Rc<dyn Fn(ContextMenuAction)>,
pub cells: Vec<Cell>,
}
#[derive(Default)]
pub struct ContextMenu {
window: WguiWindow,
}
fn doc_params<'a>(globals: WguiGlobals) -> parser::ParseDocumentParams<'a> {
parser::ParseDocumentParams {
globals,
path: AssetPath::WguiInternal("wgui/context_menu.xml"),
extra: Default::default(),
}
}
impl ContextMenu {
pub fn open(&mut self, params: &mut OpenParams) -> anyhow::Result<()> {
self.window.open(&mut WguiWindowParams {
globals: params.globals,
layout: params.layout,
position: params.position,
extra: WguiWindowParamsExtra {
with_decorations: false,
close_if_clicked_outside: true,
..Default::default()
},
})?;
let content = self.window.get_content();
let mut state = parser::parse_from_assets(&doc_params(params.globals.clone()), params.layout, content.id)?;
let id_buttons = state.get_widget_id("buttons")?;
for (idx, cell) in params.cells.iter().enumerate() {
let mut par = HashMap::new();
par.insert(Rc::from("text"), cell.title.generate(&mut params.globals.i18n()));
let data_cell = state.parse_template(
&doc_params(params.globals.clone()),
"Cell",
params.layout,
id_buttons,
par,
)?;
let button = data_cell.fetch_component_as::<ComponentButton>("button")?;
button.on_click({
let on_action = params.on_action.clone();
let name = cell.action_name.clone();
let window = self.window.clone();
Box::new(move |common, _| {
(*on_action)(ContextMenuAction {
name: name.clone(),
// FIXME: why i can't just provide this as-is!?
/* common: common, */
common: &mut CallbackDataCommon {
alterables: common.alterables,
state: common.state,
},
});
window.close();
Ok(())
})
});
if idx < params.cells.len() - 1 {
state.parse_template(
&doc_params(params.globals.clone()),
"Separator",
params.layout,
id_buttons,
Default::default(),
)?;
}
}
Ok(())
}
pub fn close(&self) {
self.window.close();
}
}

View File

@@ -0,0 +1,2 @@
pub mod context_menu;
pub mod window;

View File

@@ -0,0 +1,301 @@
use std::{cell::RefCell, rc::Rc};
use glam::Vec2;
use taffy::prelude::{length, percent};
use crate::{
animation::{Animation, AnimationEasing},
assets::AssetPath,
components::button::ComponentButton,
drawing,
event::{EventListenerKind, StyleSetRequest},
globals::WguiGlobals,
i18n::Translation,
layout::{Layout, LayoutTask, LayoutTasks, WidgetPair},
parser::{self, Fetchable, ParserState},
widget::{
EventResult,
div::WidgetDiv,
label::WidgetLabel,
rectangle::{WidgetRectangle, WidgetRectangleParams},
},
};
struct OpenedWindow {
layout_tasks: LayoutTasks,
widget: WidgetPair,
input_grabber: Option<WidgetPair>,
content: WidgetPair,
#[allow(dead_code)]
state: Option<ParserState>,
}
impl Drop for OpenedWindow {
fn drop(&mut self) {
self.layout_tasks.push(LayoutTask::RemoveWidget(self.widget.id));
if let Some(grabber) = &self.input_grabber {
self.layout_tasks.push(LayoutTask::RemoveWidget(grabber.id));
}
}
}
struct State {
opened_window: Option<OpenedWindow>,
}
#[derive(Clone)]
pub struct WguiWindow(Rc<RefCell<State>>);
pub struct OnContentData {
pub widget: WidgetPair,
}
#[derive(Default)]
pub enum WguiWindowPlacement {
#[default]
TopLeft,
BottomLeft,
TopRight,
BottomRight,
}
pub struct WguiWindowParamsExtra {
pub fixed_width: Option<f32>,
pub fixed_height: Option<f32>,
pub placement: WguiWindowPlacement,
pub with_decorations: bool,
pub no_decoration_padding: f32,
pub close_if_clicked_outside: bool,
pub title: Option<Translation>,
}
impl Default for WguiWindowParamsExtra {
fn default() -> Self {
Self {
fixed_width: None,
fixed_height: None,
title: None,
placement: WguiWindowPlacement::TopLeft,
no_decoration_padding: 0.0,
close_if_clicked_outside: false,
with_decorations: true,
}
}
}
pub struct WguiWindowParams<'a> {
pub position: Vec2,
pub globals: &'a WguiGlobals,
pub layout: &'a mut Layout,
pub extra: WguiWindowParamsExtra,
}
impl Default for WguiWindow {
fn default() -> Self {
Self(Rc::new(RefCell::new(State { opened_window: None })))
}
}
const WINDOW_DECORATION_PADDING: f32 = 2.0;
const WINDOW_DECORATION_HEADER_HEIGHT: f32 = 32.0;
impl WguiWindow {
pub fn close(&self) {
self.0.borrow_mut().opened_window = None;
}
#[allow(clippy::too_many_lines)]
pub fn open(&mut self, params: &mut WguiWindowParams) -> anyhow::Result<()> {
// close previous one if it's already open
self.close();
let header_height = if params.extra.with_decorations {
WINDOW_DECORATION_HEADER_HEIGHT
} else {
0.0
};
let window_padding = if params.extra.with_decorations {
WINDOW_DECORATION_PADDING
} else {
params.extra.no_decoration_padding
};
let (padding, justify_content, align_items) = match params.extra.placement {
WguiWindowPlacement::TopLeft => (
taffy::Rect {
left: length(params.position.x - window_padding),
top: length(params.position.y - header_height - window_padding),
bottom: length(0.0),
right: length(0.0),
},
taffy::JustifyContent::Start, // x start
taffy::AlignItems::Start, // y start
),
WguiWindowPlacement::BottomLeft => (
taffy::Rect {
left: length(params.position.x - window_padding),
top: length(0.0),
bottom: length(params.position.y - window_padding),
right: length(0.0),
},
taffy::JustifyContent::Start, // x start
taffy::AlignItems::End, // y end
),
WguiWindowPlacement::TopRight => (
taffy::Rect {
left: length(0.0),
top: length(params.position.y - header_height - window_padding),
bottom: length(0.0),
right: length(params.position.x - window_padding),
},
taffy::JustifyContent::End, // x end
taffy::AlignItems::Start, // y start
),
WguiWindowPlacement::BottomRight => (
taffy::Rect {
left: length(0.0),
top: length(0.0),
bottom: length(params.position.y - window_padding),
right: length(params.position.x - window_padding),
},
taffy::JustifyContent::End, // x end
taffy::AlignItems::End, // y end
),
};
let input_grabber = if params.extra.close_if_clicked_outside {
let mut rect = WidgetRectangle::create(Default::default());
rect.flags.consume_mouse_events = true;
let this = self.clone();
rect.event_listeners.register(
EventListenerKind::MousePress,
Box::new(move |_, _, (), ()| {
this.close();
Ok(EventResult::Consumed)
}),
);
let (widget, _) = params.layout.add_topmost_child(
rect,
taffy::Style {
position: taffy::Position::Absolute,
size: taffy::Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
},
)?;
// Fade animation
params.layout.animations.add(Animation::new(
widget.id,
20,
AnimationEasing::OutQuad,
Box::new(|common, data| {
let rect = data.obj.get_as_mut::<WidgetRectangle>().unwrap() /* should always succeed */;
rect.params.color = drawing::Color::new(0.0, 0.0, 0.0, data.pos * 0.3);
common.alterables.mark_redraw();
}),
));
Some(widget)
} else {
None
};
let (widget, _) = params.layout.add_topmost_child(
WidgetDiv::create(),
taffy::Style {
position: taffy::Position::Absolute,
align_items: Some(align_items),
justify_content: Some(justify_content),
padding,
size: taffy::Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
},
)?;
let content_id = if params.extra.with_decorations {
let xml_path: AssetPath = AssetPath::WguiInternal("wgui/window_frame.xml");
let state = parser::parse_from_assets(
&parser::ParseDocumentParams {
globals: params.globals.clone(),
path: xml_path,
extra: Default::default(),
},
params.layout,
widget.id,
)?;
let but_close = state.fetch_component_as::<ComponentButton>("but_close").unwrap();
but_close.on_click({
let this = self.clone();
Box::new(move |_common, _e| {
this.close();
Ok(())
})
});
if let Some(title) = &params.extra.title {
let mut text_title = state.fetch_widget_as::<WidgetLabel>(&params.layout.state, "text_window_title")?;
text_title.set_text_simple(&mut params.globals.get(), title.clone());
}
let content = state.fetch_widget(&params.layout.state, "content")?;
self.0.borrow_mut().opened_window = Some(OpenedWindow {
widget,
state: Some(state),
layout_tasks: params.layout.tasks.clone(),
content: content.clone(),
input_grabber,
});
content.id
} else {
// without decorations
let (content, _) = params
.layout
.add_child(widget.id, WidgetDiv::create(), Default::default())?;
self.0.borrow_mut().opened_window = Some(OpenedWindow {
widget,
state: None,
layout_tasks: params.layout.tasks.clone(),
content: content.clone(),
input_grabber,
});
content.id
};
let mut c = params.layout.start_common();
if let Some(width) = params.extra.fixed_width {
c.common()
.alterables
.set_style(content_id, StyleSetRequest::Width(length(width)));
}
if let Some(height) = params.extra.fixed_height {
c.common()
.alterables
.set_style(content_id, StyleSetRequest::Height(length(height)));
}
c.finish()?;
Ok(())
}
pub fn get_content(&self) -> WidgetPair {
let state = self.0.borrow_mut();
state.opened_window.as_ref().unwrap().content.clone()
}
}