diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:50 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:50 +0000 |
commit | def92d1b8e9d373e2f6f27c366d578d97d8960c6 (patch) | |
tree | 2ef34b9ad8bb9a9220e05d60352558b15f513894 /toolkit/crashreporter/client/app/src/ui | |
parent | Adding debian version 125.0.3-1. (diff) | |
download | firefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.tar.xz firefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.zip |
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/crashreporter/client/app/src/ui')
25 files changed, 5284 insertions, 0 deletions
diff --git a/toolkit/crashreporter/client/app/src/ui/crashreporter.png b/toolkit/crashreporter/client/app/src/ui/crashreporter.png Binary files differnew file mode 100644 index 0000000000..5e68bac17c --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/crashreporter.png diff --git a/toolkit/crashreporter/client/app/src/ui/gtk.rs b/toolkit/crashreporter/client/app/src/ui/gtk.rs new file mode 100644 index 0000000000..a76f99b0bd --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/gtk.rs @@ -0,0 +1,841 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::model::{self, Alignment, Application, Element}; +use crate::std::{ + cell::RefCell, + ffi::{c_char, CString}, + rc::Rc, + sync::atomic::{AtomicBool, Ordering::Relaxed}, +}; +use crate::{ + data::{Event, Property, Synchronized}, + std, +}; + +/// Create a `std::ffi::CStr` directly from a literal string. +/// +/// The argument is an `expr` rather than `literal` so that other macros can be used (such as +/// `stringify!`). +macro_rules! cstr { + ( $str:expr ) => { + #[allow(unused_unsafe)] + unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(concat!($str, "\0").as_bytes()) } + .as_ptr() + }; +} + +/// A GTK+ UI implementation. +#[derive(Default)] +pub struct UI { + running: AtomicBool, +} + +impl UI { + pub fn run_loop(&self, app: Application) { + unsafe { + let stream = gtk::g_memory_input_stream_new_from_data( + super::icon::PNG_DATA.as_ptr() as _, + // unwrap() because the PNG_DATA length will be well within gssize limits (32-bit + // at the smallest). + super::icon::PNG_DATA.len().try_into().unwrap(), + None, + ); + let icon_pixbuf = + gtk::gdk_pixbuf_new_from_stream(stream, std::ptr::null_mut(), std::ptr::null_mut()); + gtk::g_object_unref(stream as _); + + gtk::gtk_window_set_default_icon(icon_pixbuf); + + let app_ptr = gtk::gtk_application_new( + std::ptr::null(), + gtk::GApplicationFlags_G_APPLICATION_FLAGS_NONE, + ); + gtk::g_signal_connect_data( + app_ptr as *mut _, + cstr!("activate"), + Some(std::mem::transmute( + render_application + as for<'a> unsafe extern "C" fn(*mut gtk::GtkApplication, &'a Application), + )), + &app as *const Application as *mut Application as _, + None, + 0, + ); + self.running.store(true, Relaxed); + gtk::g_application_run(app_ptr as *mut gtk::GApplication, 0, std::ptr::null_mut()); + self.running.store(false, Relaxed); + gtk::g_object_unref(app_ptr as *mut _); + gtk::g_object_unref(icon_pixbuf as _); + } + } + + pub fn invoke(&self, f: model::InvokeFn) { + if !self.running.load(Relaxed) { + log::debug!("ignoring `invoke` as main loop isn't running"); + return; + } + type BoxedData = Option<model::InvokeFn>; + + unsafe extern "C" fn call(ptr: gtk::gpointer) -> gtk::gboolean { + let f: &mut BoxedData = ToPointer::from_ptr(ptr as _); + f.take().unwrap()(); + false.into() + } + + unsafe extern "C" fn drop(ptr: gtk::gpointer) { + let _: Box<BoxedData> = ToPointer::from_ptr(ptr as _); + } + + let data: Box<BoxedData> = Box::new(Some(f)); + + unsafe { + let main_context = gtk::g_main_context_default(); + gtk::g_main_context_invoke_full( + main_context, + 0, // G_PRIORITY_DEFAULT + Some(call as unsafe extern "C" fn(gtk::gpointer) -> gtk::gboolean), + data.to_ptr() as _, + Some(drop as unsafe extern "C" fn(gtk::gpointer)), + ); + } + } +} + +/// Types that can be converted to and from a pointer. +/// +/// These types must be sized to avoid fat pointers (i.e., the pointers must be FFI-compatible, the +/// same size as usize). +trait ToPointer: Sized { + fn to_ptr(self) -> *mut (); + /// # Safety + /// The caller must ensure that the pointer was created as the result of `to_ptr` on the same + /// or a compatible type, and that the data is still valid. + unsafe fn from_ptr(ptr: *mut ()) -> Self; +} + +/// Types that can be attached to a GLib object to be dropped when the widget is dropped. +trait DropWithObject: Sized { + fn drop_with_object(self, object: *mut gtk::GObject); + fn drop_with_widget(self, widget: *mut gtk::GtkWidget) { + self.drop_with_object(widget as *mut _); + } + + fn set_data(self, object: *mut gtk::GObject, key: *const c_char); +} + +impl<T: ToPointer> DropWithObject for T { + fn drop_with_object(self, object: *mut gtk::GObject) { + unsafe extern "C" fn free_ptr<T: ToPointer>( + ptr: gtk::gpointer, + _object: *mut gtk::GObject, + ) { + drop(T::from_ptr(ptr as *mut ())); + } + unsafe { gtk::g_object_weak_ref(object, Some(free_ptr::<T>), self.to_ptr() as *mut _) }; + } + + fn set_data(self, object: *mut gtk::GObject, key: *const c_char) { + unsafe extern "C" fn free_ptr<T: ToPointer>(ptr: gtk::gpointer) { + drop(T::from_ptr(ptr as *mut ())); + } + unsafe { + gtk::g_object_set_data_full(object, key, self.to_ptr() as *mut _, Some(free_ptr::<T>)) + }; + } +} + +impl ToPointer for CString { + fn to_ptr(self) -> *mut () { + self.into_raw() as _ + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + CString::from_raw(ptr as *mut c_char) + } +} + +impl<T> ToPointer for Rc<T> { + fn to_ptr(self) -> *mut () { + Rc::into_raw(self) as *mut T as *mut () + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + Rc::from_raw(ptr as *mut T as *const T) + } +} + +impl<T> ToPointer for Box<T> { + fn to_ptr(self) -> *mut () { + Box::into_raw(self) as _ + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + Box::from_raw(ptr as _) + } +} + +impl<T: Sized> ToPointer for &mut T { + fn to_ptr(self) -> *mut () { + self as *mut T as _ + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + &mut *(ptr as *mut T) + } +} + +/// Connect a GTK+ object signal to a function, providing an additional context value (by +/// reference). +macro_rules! connect_signal { + ( object $object:expr ; with $with:expr ; + signal $name:ident ($target:ident : &$type:ty $(, $argname:ident : $argtype:ty )* ) $( -> $ret:ty )? $body:block + ) => {{ + unsafe extern "C" fn $name($($argname : $argtype ,)* $target: &$type) $( -> $ret )? $body + #[allow(unused_unsafe)] + unsafe { + gtk::g_signal_connect_data( + $object as *mut _, + cstr!(stringify!($name)), + Some(std::mem::transmute( + $name + as for<'a> unsafe extern "C" fn( + $($argtype,)* + &'a $type, + ) $( -> $ret )?, + )), + $with as *const $type as *mut $type as _, + None, + 0, + ); + } + }}; +} + +/// Bind a read only (from the renderer perspective) property to a widget. +/// +/// The `set` function is called initially and when the property value changes. +macro_rules! property_read_only { + ( property $property:expr ; + fn set( $name:ident : & $type:ty ) $setbody:block + ) => {{ + let prop: &Property<$type> = $property; + match prop { + Property::Static($name) => $setbody, + Property::Binding(v) => { + { + let $name = v.borrow(); + $setbody + } + v.on_change(move |$name| $setbody); + } + Property::ReadOnly(_) => (), + } + }}; +} + +/// Bind a read/write property to a widget signal. +/// +/// This currently only allows signals which are of the form +/// `void(*)(SomeGtkObject*, gpointer user_data)`. +macro_rules! property_with_signal { + ( object $object:expr ; property $property:expr ; signal $signame:ident ; + fn set( $name:ident : & $type:ty ) $setbody:block + fn get( $getobj:ident : $gettype:ty ) -> $result:ty $getbody:block + ) => {{ + let prop: &Property<$type> = $property; + match prop { + Property::Static($name) => $setbody, + Property::Binding(v) => { + { + let $name = v.borrow(); + $setbody + } + let changing = Rc::new(RefCell::new(false)); + struct SignalData { + changing: Rc<RefCell<bool>>, + value: Synchronized<$result>, + } + let signal_data = Box::new(SignalData { + changing: changing.clone(), + value: v.clone(), + }); + v.on_change(move |$name| { + if !*changing.borrow() { + *changing.borrow_mut() = true; + $setbody; + *changing.borrow_mut() = false; + } + }); + connect_signal! { + object $object; + with signal_data.as_ref(); + signal $signame(signal_data: &SignalData, $getobj: $gettype) { + let new_value = (|| $getbody)(); + if !*signal_data.changing.borrow() { + *signal_data.changing.borrow_mut() = true; + *signal_data.value.borrow_mut() = new_value; + *signal_data.changing.borrow_mut() = false; + } + } + } + signal_data.drop_with_object($object as _); + } + Property::ReadOnly(v) => { + v.register(move |target: &mut $result| { + let $getobj: $gettype = $object as _; + *target = (|| $getbody)(); + }); + } + } + }}; +} + +unsafe extern "C" fn render_application(app_ptr: *mut gtk::GtkApplication, app: &Application) { + unsafe { + gtk::gtk_widget_set_default_direction(if app.rtl { + gtk::GtkTextDirection_GTK_TEXT_DIR_RTL + } else { + gtk::GtkTextDirection_GTK_TEXT_DIR_LTR + }); + } + for window in &app.windows { + let window_ptr = render_window(&window.element_type); + let style = &window.style; + + // Set size before applying style (since the style will set the visibility and show the + // window). Note that we take the size request as an initial size here. + // + // `gtk_window_set_default_size` doesn't work; it resizes to the size request of the inner + // labels (instead of wrapping them) since it doesn't know how small they should be (that's + // dictated by the window size!). + unsafe { + gtk::gtk_window_resize( + window_ptr as _, + style + .horizontal_size_request + .map(|v| v as i32) + .unwrap_or(-1), + style.vertical_size_request.map(|v| v as i32).unwrap_or(-1), + ); + } + + apply_style(window_ptr, style); + unsafe { + gtk::gtk_application_add_window(app_ptr, window_ptr as *mut _); + } + } +} + +fn render(element: &Element) -> Option<*mut gtk::GtkWidget> { + let widget = render_element_type(&element.element_type)?; + apply_style(widget, &element.style); + Some(widget) +} + +fn apply_style(widget: *mut gtk::GtkWidget, style: &model::ElementStyle) { + unsafe { + gtk::gtk_widget_set_halign(widget, alignment(&style.horizontal_alignment)); + if style.horizontal_alignment == Alignment::Fill { + gtk::gtk_widget_set_hexpand(widget, true.into()); + } + gtk::gtk_widget_set_valign(widget, alignment(&style.vertical_alignment)); + if style.vertical_alignment == Alignment::Fill { + gtk::gtk_widget_set_vexpand(widget, true.into()); + } + gtk::gtk_widget_set_size_request( + widget, + style + .horizontal_size_request + .map(|v| v as i32) + .unwrap_or(-1), + style.vertical_size_request.map(|v| v as i32).unwrap_or(-1), + ); + + gtk::gtk_widget_set_margin_start(widget, style.margin.start as i32); + gtk::gtk_widget_set_margin_end(widget, style.margin.end as i32); + gtk::gtk_widget_set_margin_top(widget, style.margin.top as i32); + gtk::gtk_widget_set_margin_bottom(widget, style.margin.bottom as i32); + } + property_read_only! { + property &style.visible; + fn set(new_value: &bool) { + unsafe { + gtk::gtk_widget_set_visible(widget, new_value.clone().into()); + }; + } + } + property_read_only! { + property &style.enabled; + fn set(new_value: &bool) { + unsafe { + gtk::gtk_widget_set_sensitive(widget, new_value.clone().into()); + } + } + } +} + +fn alignment(align: &Alignment) -> gtk::GtkAlign { + use Alignment::*; + match align { + Start => gtk::GtkAlign_GTK_ALIGN_START, + Center => gtk::GtkAlign_GTK_ALIGN_CENTER, + End => gtk::GtkAlign_GTK_ALIGN_END, + Fill => gtk::GtkAlign_GTK_ALIGN_FILL, + } +} + +struct PangoAttrList { + list: *mut gtk::PangoAttrList, +} + +impl PangoAttrList { + pub fn new() -> Self { + PangoAttrList { + list: unsafe { gtk::pango_attr_list_new() }, + } + } + + pub fn bold(&mut self) -> &mut Self { + unsafe { + gtk::pango_attr_list_insert( + self.list, + gtk::pango_attr_weight_new(gtk::PangoWeight_PANGO_WEIGHT_BOLD), + ) + }; + self + } +} + +impl std::ops::Deref for PangoAttrList { + type Target = *mut gtk::PangoAttrList; + + fn deref(&self) -> &Self::Target { + &self.list + } +} + +impl std::ops::DerefMut for PangoAttrList { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.list + } +} + +impl Drop for PangoAttrList { + fn drop(&mut self) { + unsafe { gtk::pango_attr_list_unref(self.list) }; + } +} + +fn render_element_type(element_type: &model::ElementType) -> Option<*mut gtk::GtkWidget> { + use model::ElementType::*; + Some(match element_type { + Label(model::Label { text, bold }) => { + let label_ptr = unsafe { gtk::gtk_label_new(std::ptr::null()) }; + match text { + Property::Static(text) => { + let text = CString::new(text.clone()).ok()?; + unsafe { gtk::gtk_label_set_text(label_ptr as _, text.as_ptr()) }; + text.drop_with_widget(label_ptr); + } + Property::Binding(b) => { + let label_text = Rc::new(RefCell::new(CString::new(b.borrow().clone()).ok()?)); + unsafe { + gtk::gtk_label_set_text(label_ptr as _, label_text.borrow().as_ptr()) + }; + let lt = label_text.clone(); + label_text.drop_with_widget(label_ptr); + b.on_change(move |t| { + let Some(cstr) = CString::new(t.clone()).ok() else { + return; + }; + unsafe { gtk::gtk_label_set_text(label_ptr as _, cstr.as_ptr()) }; + *lt.borrow_mut() = cstr; + }); + } + Property::ReadOnly(_) => unimplemented!("ReadOnly not supported for Label::text"), + } + unsafe { gtk::gtk_label_set_line_wrap(label_ptr as _, true.into()) }; + // This is gtk_label_set_{xalign,yalign} in gtk 3.16+ + unsafe { gtk::gtk_misc_set_alignment(label_ptr as _, 0.0, 0.5) }; + if *bold { + unsafe { + gtk::gtk_label_set_attributes(label_ptr as _, **PangoAttrList::new().bold()) + }; + } + label_ptr + } + HBox(model::HBox { + items, + spacing, + affirmative_order, + }) => { + let box_ptr = + unsafe { gtk::gtk_box_new(gtk::GtkOrientation_GTK_ORIENTATION_HORIZONTAL, 0) }; + unsafe { gtk::gtk_box_set_spacing(box_ptr as *mut _, *spacing as i32) }; + let items_iter: Box<dyn Iterator<Item = &Element>> = if *affirmative_order { + Box::new(items.iter().rev()) + } else { + Box::new(items.iter()) + }; + for item in items_iter { + if let Some(widget) = render(item) { + unsafe { + gtk::gtk_container_add(box_ptr as *mut gtk::GtkContainer, widget); + } + // Special case horizontal alignment to pack into the end if appropriate + if item.style.horizontal_alignment == Alignment::End { + unsafe { + gtk::gtk_box_set_child_packing( + box_ptr as _, + widget, + false.into(), + false.into(), + 0, + gtk::GtkPackType_GTK_PACK_END, + ); + } + } + } + } + box_ptr + } + VBox(model::VBox { items, spacing }) => { + let box_ptr = + unsafe { gtk::gtk_box_new(gtk::GtkOrientation_GTK_ORIENTATION_VERTICAL, 0) }; + unsafe { gtk::gtk_box_set_spacing(box_ptr as *mut _, *spacing as i32) }; + for item in items { + if let Some(widget) = render(item) { + unsafe { + gtk::gtk_container_add(box_ptr as *mut gtk::GtkContainer, widget); + } + // Special case vertical alignment to pack into the end if appropriate + if item.style.vertical_alignment == Alignment::End { + unsafe { + gtk::gtk_box_set_child_packing( + box_ptr as _, + widget, + false.into(), + false.into(), + 0, + gtk::GtkPackType_GTK_PACK_END, + ); + } + } + } + } + box_ptr + } + Button(model::Button { content, click }) => { + let button_ptr = unsafe { gtk::gtk_button_new() }; + if let Some(widget) = content.as_deref().and_then(render) { + unsafe { + // Always center widgets in buttons. + gtk::gtk_widget_set_valign(widget, alignment(&Alignment::Center)); + gtk::gtk_widget_set_halign(widget, alignment(&Alignment::Center)); + gtk::gtk_container_add(button_ptr as *mut gtk::GtkContainer, widget); + } + } + connect_signal! { + object button_ptr; + with click; + signal clicked(event: &Event<()>, _button: *mut gtk::GtkButton) { + event.fire(&()); + } + } + button_ptr + } + Checkbox(model::Checkbox { checked, label }) => { + let cb_ptr = match label { + None => unsafe { gtk::gtk_check_button_new() }, + Some(s) => { + let label = CString::new(s.clone()).ok()?; + let cb = unsafe { gtk::gtk_check_button_new_with_label(label.as_ptr()) }; + label.drop_with_widget(cb); + cb + } + }; + property_with_signal! { + object cb_ptr; + property checked; + signal toggled; + fn set(new_value: &bool) { + unsafe { + gtk::gtk_toggle_button_set_active(cb_ptr as *mut _, new_value.clone().into()) + }; + } + fn get(button: *mut gtk::GtkToggleButton) -> bool { + unsafe { + gtk::gtk_toggle_button_get_active(button) == 1 + } + } + } + cb_ptr + } + TextBox(model::TextBox { + placeholder, + content, + editable, + }) => { + let text_ptr = unsafe { gtk::gtk_text_view_new() }; + unsafe { + const GTK_WRAP_WORD_CHAR: u32 = 3; + gtk::gtk_text_view_set_wrap_mode(text_ptr as *mut _, GTK_WRAP_WORD_CHAR); + gtk::gtk_text_view_set_editable(text_ptr as *mut _, editable.clone().into()); + gtk::gtk_text_view_set_accepts_tab(text_ptr as *mut _, false.into()); + } + let buffer = unsafe { gtk::gtk_text_view_get_buffer(text_ptr as *mut _) }; + + struct State { + placeholder: Option<Placeholder>, + } + + struct Placeholder { + string: CString, + visible: RefCell<bool>, + } + + impl Placeholder { + fn focus(&self, widget: *mut gtk::GtkWidget) { + if *self.visible.borrow() { + unsafe { + let buffer = gtk::gtk_text_view_get_buffer(widget as *mut _); + gtk::gtk_text_buffer_set_text(buffer, self.string.as_ptr(), 0); + gtk::gtk_widget_override_color( + widget, + gtk::GtkStateFlags_GTK_STATE_FLAG_NORMAL, + std::ptr::null(), + ); + } + *self.visible.borrow_mut() = false; + } + } + + fn unfocus(&self, widget: *mut gtk::GtkWidget) { + unsafe { + let buffer = gtk::gtk_text_view_get_buffer(widget as *mut _); + + let mut end_iter = gtk::GtkTextIter::default(); + gtk::gtk_text_buffer_get_end_iter(buffer, &mut end_iter); + let is_empty = gtk::gtk_text_iter_get_offset(&end_iter) == 0; + + if is_empty && !*self.visible.borrow() { + gtk::gtk_text_buffer_set_text(buffer, self.string.as_ptr(), -1); + let context = gtk::gtk_widget_get_style_context(widget); + let mut color = gtk::GdkRGBA::default(); + gtk::gtk_style_context_get_color( + context, + gtk::GtkStateFlags_GTK_STATE_FLAG_INSENSITIVE, + &mut color, + ); + gtk::gtk_widget_override_color( + widget, + gtk::GtkStateFlags_GTK_STATE_FLAG_NORMAL, + &color, + ); + *self.visible.borrow_mut() = true; + } + } + } + } + + let mut state = Box::new(State { placeholder: None }); + + if let Some(placeholder) = placeholder { + state.placeholder = Some(Placeholder { + string: CString::new(placeholder.clone()).ok()?, + visible: RefCell::new(false), + }); + + let placeholder = state.placeholder.as_ref().unwrap(); + + placeholder.unfocus(text_ptr); + + connect_signal! { + object text_ptr; + with placeholder; + signal focus_in_event(placeholder: &Placeholder, widget: *mut gtk::GtkWidget, + _event: *mut gtk::GdkEventFocus) -> gtk::gboolean { + placeholder.focus(widget); + false.into() + } + } + connect_signal! { + object text_ptr; + with placeholder; + signal focus_out_event(placeholder: &Placeholder, widget: *mut gtk::GtkWidget, + _event: *mut gtk::GdkEventFocus) -> gtk::gboolean { + placeholder.unfocus(widget); + false.into() + } + } + } + + // Attach the state so that we can access it in the changed signal. + // This is kind of ugly; in the future it might be a nicer developer interface to simply + // always use a closure as the user data (which itself can capture arbitrary things). This + // would move the data management from GTK to rust. + state.set_data(buffer as *mut _, cstr!("textview-state")); + + property_with_signal! { + object buffer; + property content; + signal changed; + fn set(new_value: &String) { + unsafe { + gtk::gtk_text_buffer_set_text(buffer, new_value.as_ptr() as *const c_char, new_value.len().try_into().unwrap()); + } + } + fn get(buffer: *mut gtk::GtkTextBuffer) -> String { + let state = unsafe { + gtk::g_object_get_data(buffer as *mut _, cstr!("textview-state")) + }; + if !state.is_null() { + let s: &State = unsafe { &*(state as *mut State as *const State) }; + if let Some(placeholder) = &s.placeholder { + if *placeholder.visible.borrow() { + return "".to_owned(); + } + } + } + let mut start_iter = gtk::GtkTextIter::default(); + unsafe { + gtk::gtk_text_buffer_get_start_iter(buffer, &mut start_iter); + } + + let mut s = String::new(); + loop { + let c = unsafe { gtk::gtk_text_iter_get_char(&start_iter) }; + if c == 0 { + break; + } + // Safety: + // gunichar is guaranteed to be a valid unicode codepoint (if nonzero). + s.push(unsafe { char::from_u32_unchecked(c) }); + unsafe { + gtk::gtk_text_iter_forward_char(&mut start_iter); + } + } + s + } + } + text_ptr + } + Scroll(model::Scroll { content }) => { + let scroll_ptr = + unsafe { gtk::gtk_scrolled_window_new(std::ptr::null_mut(), std::ptr::null_mut()) }; + unsafe { + gtk::gtk_scrolled_window_set_policy( + scroll_ptr as *mut _, + gtk::GtkPolicyType_GTK_POLICY_NEVER, + gtk::GtkPolicyType_GTK_POLICY_ALWAYS, + ); + gtk::gtk_scrolled_window_set_shadow_type( + scroll_ptr as *mut _, + gtk::GtkShadowType_GTK_SHADOW_IN, + ); + }; + if let Some(widget) = content.as_deref().and_then(render) { + unsafe { + gtk::gtk_container_add(scroll_ptr as *mut gtk::GtkContainer, widget); + } + } + scroll_ptr + } + Progress(model::Progress { amount }) => { + let progress_ptr = unsafe { gtk::gtk_progress_bar_new() }; + property_read_only! { + property amount; + fn set(value: &Option<f32>) { + match &*value { + Some(v) => unsafe { + gtk::gtk_progress_bar_set_fraction( + progress_ptr as *mut _, + v.clamp(0f32,1f32) as f64, + ); + } + None => unsafe { + gtk::gtk_progress_bar_pulse(progress_ptr as *mut _); + + fn auto_pulse_progress_bar(progress: *mut gtk::GtkProgressBar) { + unsafe extern fn pulse(progress: *mut std::ffi::c_void) -> gtk::gboolean { + if gtk::gtk_widget_is_visible(progress as _) == 0 { + false.into() + } else { + gtk::gtk_progress_bar_pulse(progress as _); + true.into() + } + } + unsafe { + gtk::g_timeout_add(100, Some(pulse as unsafe extern fn(*mut std::ffi::c_void) -> gtk::gboolean), progress as _); + } + + } + + connect_signal! { + object progress_ptr; + with std::ptr::null_mut(); + signal show(_user_data: &(), progress: *mut gtk::GtkWidget) { + auto_pulse_progress_bar(progress as *mut _); + } + } + auto_pulse_progress_bar(progress_ptr as *mut _); + } + } + } + } + progress_ptr + } + }) +} + +fn render_window( + model::Window { + title, + content, + children, + modal, + close, + }: &model::Window, +) -> *mut gtk::GtkWidget { + unsafe { + let window_ptr = gtk::gtk_window_new(gtk::GtkWindowType_GTK_WINDOW_TOPLEVEL); + if !title.is_empty() { + if let Some(title) = CString::new(title.clone()).ok() { + gtk::gtk_window_set_title(window_ptr as *mut _, title.as_ptr()); + title.drop_with_widget(window_ptr); + } + } + if let Some(content) = content { + if let Some(widget) = render(content) { + gtk::gtk_container_add(window_ptr as *mut gtk::GtkContainer, widget); + } + } + for child in children { + let widget = render_window(&child.element_type); + apply_style(widget, &child.style); + gtk::gtk_window_set_transient_for(widget as *mut _, window_ptr as *mut _); + // Delete should hide the window. + gtk::g_signal_connect_data( + widget as *mut _, + cstr!("delete-event"), + Some(std::mem::transmute( + gtk::gtk_widget_hide_on_delete + as unsafe extern "C" fn(*mut gtk::GtkWidget) -> i32, + )), + std::ptr::null_mut(), + None, + 0, + ); + } + if *modal { + gtk::gtk_window_set_modal(window_ptr as *mut _, true.into()); + } + if let Some(close) = close { + close.subscribe(move |&()| gtk::gtk_window_close(window_ptr as *mut _)); + } + + window_ptr + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/macos/mod.rs b/toolkit/crashreporter/client/app/src/ui/macos/mod.rs new file mode 100644 index 0000000000..6520d16472 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/macos/mod.rs @@ -0,0 +1,1122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! A UI using the macos cocoa API. +//! +//! This UI contains some edge cases that aren't implemented, for instance: +//! * there are a few cases where specific hierarchies are handled differently (e.g. a Button +//! containing Label), etc. +//! * not all controls handle all Property variants (e.g. Checkbox doesn't handle ReadOnly, Text +//! doesn't handle Binding, etc). +//! +//! The rendering ignores `ElementStyle::margin` entirely, because +//! * `NSView` doesn't support margins (so working them into constraints would be a bit annoying), +//! and +//! * `NSView.layoutMarginsGuide` results in a layout almost identical to what the margins (at the +//! time of this writing) in the UI layouts are achieving. +//! +//! In a few cases, init or creation functions are called which _could_ return nil and are wrapped +//! in their type wrapper (as those functions return `instancetype`/`id`). We consider this safe +//! enough because it won't cause unsoundness (they are only passed to objc functions which can +//! take nil arguments) and the failure case is very unlikely. + +#![allow(non_upper_case_globals)] + +use self::objc::*; +use super::model::{self, Alignment, Application, Element, TypedElement}; +use crate::data::Property; +use cocoa::{ + INSApplication, INSBox, INSButton, INSColor, INSControl, INSFont, INSLayoutAnchor, + INSLayoutConstraint, INSLayoutDimension, INSLayoutGuide, INSMenu, INSMenuItem, + INSMutableParagraphStyle, INSObject, INSProcessInfo, INSProgressIndicator, INSRunLoop, + INSScrollView, INSStackView, INSText, INSTextContainer, INSTextField, INSTextView, INSView, + INSWindow, NSArray_NSArrayCreation, NSAttributedString_NSExtendedAttributedString, + NSDictionary_NSDictionaryCreation, NSRunLoop_NSRunLoopConveniences, + NSStackView_NSStackViewGravityAreas, NSString_NSStringExtensionMethods, + NSTextField_NSTextFieldConvenience, NSView_NSConstraintBasedLayoutInstallingConstraints, + NSView_NSConstraintBasedLayoutLayering, NSView_NSSafeAreas, PNSObject, +}; +use once_cell::sync::Lazy; + +/// https://developer.apple.com/documentation/foundation/1497293-string_encodings/nsutf8stringencoding?language=objc +const NSUTF8StringEncoding: cocoa::NSStringEncoding = 4; + +/// Constant from NSCell.h +const NSControlStateValueOn: cocoa::NSControlStateValue = 1; + +/// Constant from NSLayoutConstraint.h +const NSLayoutPriorityDefaultHigh: cocoa::NSLayoutPriority = 750.0; + +mod objc; + +/// A MacOS Cocoa UI implementation. +#[derive(Default)] +pub struct UI; + +impl UI { + pub fn run_loop(&self, app: Application) { + let nsapp = unsafe { cocoa::NSApplication::sharedApplication() }; + + Objc::<AppDelegate>::register(); + Objc::<Button>::register(); + Objc::<Checkbox>::register(); + Objc::<TextView>::register(); + Objc::<Window>::register(); + + rc::autoreleasepool(|| { + let delegate = AppDelegate::new(app).into_object(); + // Set delegate + unsafe { nsapp.setDelegate_(delegate.instance as *mut _) }; + + // Set up the main menu + unsafe { + let appname = read_nsstring(cocoa::NSProcessInfo::processInfo().processName()); + let mainmenu = StrongRef::new(cocoa::NSMenu::alloc()); + mainmenu.init(); + + { + // We don't need a title for the app menu item nor menu; it will always come from + // the process name regardless of what we set. + let appmenuitem = StrongRef::new(cocoa::NSMenuItem::alloc()).autorelease(); + appmenuitem.init(); + mainmenu.addItem_(appmenuitem); + + let appmenu = StrongRef::new(cocoa::NSMenu::alloc()); + appmenu.init(); + + { + let quit = StrongRef::new(cocoa::NSMenuItem::alloc()); + quit.initWithTitle_action_keyEquivalent_( + nsstring(&format!("Quit {appname}")), + sel!(terminate:), + nsstring("q"), + ); + appmenu.addItem_(quit.autorelease()); + } + appmenuitem.setSubmenu_(appmenu.autorelease()); + } + { + let editmenuitem = StrongRef::new(cocoa::NSMenuItem::alloc()).autorelease(); + editmenuitem.initWithTitle_action_keyEquivalent_( + nsstring("Edit"), + runtime::Sel::from_ptr(std::ptr::null()), + nsstring(""), + ); + mainmenu.addItem_(editmenuitem); + + let editmenu = StrongRef::new(cocoa::NSMenu::alloc()); + editmenu.initWithTitle_(nsstring("Edit")); + + let add_item = |name, selector, shortcut| { + let item = StrongRef::new(cocoa::NSMenuItem::alloc()); + item.initWithTitle_action_keyEquivalent_( + nsstring(name), + selector, + nsstring(shortcut), + ); + editmenu.addItem_(item.autorelease()); + }; + + add_item("Undo", sel!(undo:), "z"); + add_item("Redo", sel!(redo:), "Z"); + editmenu.addItem_(cocoa::NSMenuItem::separatorItem()); + add_item("Cut", sel!(cut:), "x"); + add_item("Copy", sel!(copy:), "c"); + add_item("Paste", sel!(paste:), "v"); + add_item("Delete", sel!(delete:), ""); + add_item("Select All", sel!(selectAll:), "a"); + + editmenuitem.setSubmenu_(editmenu.autorelease()); + } + + nsapp.setMainMenu_(mainmenu.autorelease()); + } + + // Run the main application loop + unsafe { nsapp.run() }; + }); + } + + pub fn invoke(&self, f: model::InvokeFn) { + // Blocks only take `Fn`, so we have to wrap the boxed function. + let f = std::cell::RefCell::new(Some(f)); + enqueue(move || { + if let Some(f) = f.borrow_mut().take() { + f(); + } + }); + } +} + +fn enqueue<F: Fn() + 'static>(f: F) { + let block = block::ConcreteBlock::new(f); + // The block must be an RcBlock so addOperationWithBlock can retain it. + // https://docs.rs/block/latest/block/#creating-blocks + let block = block.copy(); + + // We need to explicitly signal that the enqueued blocks can run in both the default mode (the + // main loop) and modal mode, otherwise when modal windows are opened things get stuck. + struct RunloopModes(cocoa::NSArray); + + impl RunloopModes { + pub fn new() -> Self { + unsafe { + let objects: [cocoa::id; 2] = [ + cocoa::NSDefaultRunLoopMode.0, + cocoa::NSModalPanelRunLoopMode.0, + ]; + RunloopModes( + cocoa::NSArray(<cocoa::NSArray as NSArray_NSArrayCreation< + cocoa::NSRunLoopMode, + >>::arrayWithObjects_count_( + objects.as_slice().as_ptr() as *const *mut u64, + objects + .as_slice() + .len() + .try_into() + .expect("usize can't fit in u64"), + )), + ) + } + } + } + + // # Safety + // The array is static and cannot be changed. + unsafe impl Sync for RunloopModes {} + unsafe impl Send for RunloopModes {} + + static RUNLOOP_MODES: Lazy<RunloopModes> = Lazy::new(RunloopModes::new); + + unsafe { + cocoa::NSRunLoop::mainRunLoop().performInModes_block_(RUNLOOP_MODES.0, &*block); + } +} + +#[repr(transparent)] +struct Rect(pub cocoa::NSRect); + +unsafe impl Encode for Rect { + fn encode() -> Encoding { + unsafe { Encoding::from_str("{CGRect={CGPoint=dd}{CGSize=dd}}") } + } +} + +/// Create an NSString by copying a str. +fn nsstring(v: &str) -> cocoa::NSString { + unsafe { + StrongRef::new(cocoa::NSString( + cocoa::NSString::alloc().initWithBytes_length_encoding_( + v.as_ptr() as *const _, + v.len().try_into().expect("usize can't fit in u64"), + NSUTF8StringEncoding, + ), + )) + } + .autorelease() +} + +/// Create a String by copying an NSString +fn read_nsstring(s: cocoa::NSString) -> String { + let c_str = unsafe { std::ffi::CStr::from_ptr(s.UTF8String()) }; + c_str.to_str().expect("NSString isn't UTF8").to_owned() +} + +fn nsrect<X: Into<f64>, Y: Into<f64>, W: Into<f64>, H: Into<f64>>( + x: X, + y: Y, + w: W, + h: H, +) -> cocoa::NSRect { + cocoa::NSRect { + origin: cocoa::NSPoint { + x: x.into(), + y: y.into(), + }, + size: cocoa::NSSize { + width: w.into(), + height: h.into(), + }, + } +} + +struct AppDelegate { + app: Option<Application>, + windows: Vec<StrongRef<cocoa::NSWindow>>, +} + +impl AppDelegate { + pub fn new(app: Application) -> Self { + AppDelegate { + app: Some(app), + windows: Default::default(), + } + } +} + +objc_class! { + impl AppDelegate: NSObject /*<NSApplicationDelegate>*/ { + #[sel(applicationDidFinishLaunching:)] + fn application_did_finish_launching(&mut self, _notification: Ptr<cocoa::NSNotification>) { + // Activate the application (bringing windows to the active foreground later) + unsafe { cocoa::NSApplication::sharedApplication().activateIgnoringOtherApps_(runtime::YES) }; + + let mut first = true; + let mut windows = WindowRenderer::default(); + let app = self.app.take().unwrap(); + windows.rtl = app.rtl; + for window in app.windows { + let w = windows.render(window); + unsafe { + if first { + w.makeKeyAndOrderFront_(self.instance); + w.makeMainWindow(); + first = false; + } + } + } + self.windows = windows.unwrap(); + + } + + #[sel(applicationShouldTerminateAfterLastWindowClosed:)] + fn application_should_terminate_after_window_closed(&mut self, _app: Ptr<cocoa::NSApplication>) -> runtime::BOOL { + runtime::YES + } + } +} + +struct Window { + modal: bool, + title: String, + style: model::ElementStyle, +} + +objc_class! { + impl Window: NSWindow /*<NSWindowDelegate>*/ { + #[sel(init)] + fn init(&mut self) -> cocoa::id { + let style = &self.style; + let title = &self.title; + let w = cocoa::NSWindow(self.instance); + + unsafe { + if w.initWithContentRect_styleMask_backing_defer_( + nsrect( + 0, + 0, + style.horizontal_size_request.unwrap_or(800), + style.vertical_size_request.unwrap_or(500), + ), + cocoa::NSWindowStyleMaskTitled + | cocoa::NSWindowStyleMaskClosable + | cocoa::NSWindowStyleMaskResizable + | cocoa::NSWindowStyleMaskMiniaturizable, + cocoa::NSBackingStoreBuffered, + runtime::NO, + ).is_null() { + return std::ptr::null_mut(); + } + + w.setDelegate_(self.instance as _); + w.setMinSize_(cocoa::NSSize { + width: style.horizontal_size_request.unwrap_or(0) as f64, + height: style.vertical_size_request.unwrap_or(0) as f64, + }); + + if !title.is_empty() { + w.setTitle_(nsstring(title.as_str())); + } + } + + self.instance + } + + #[sel(windowWillClose:)] + fn window_will_close(&mut self, _notification: Ptr<cocoa::NSNotification>) { + if self.modal { + unsafe { + let nsapp = cocoa::NSApplication::sharedApplication(); + nsapp.stopModal(); + } + } + } + } +} + +impl From<Objc<Window>> for cocoa::NSWindow { + fn from(ptr: Objc<Window>) -> Self { + cocoa::NSWindow(ptr.instance) + } +} + +struct Button { + element: model::Button, +} + +impl Button { + pub fn with_title(self, title: &str) -> cocoa::NSButton { + let obj = self.into_object(); + unsafe { + let () = msg_send![obj.instance, setTitle: nsstring(title)]; + } + // # Safety + // NSButton is the superclass of Objc<Button>. + unsafe { std::mem::transmute(obj.autorelease()) } + } +} + +objc_class! { + impl Button: NSButton { + #[sel(initWithFrame:)] + fn init_with_frame(&mut self, frame_rect: Rect) -> cocoa::id { + unsafe { + let object: cocoa::id = msg_send![super(self.instance, class!(NSButton)), initWithFrame: frame_rect.0]; + if object.is_null() { + return object; + } + let () = msg_send![object, setBezelStyle: cocoa::NSBezelStyleRounded]; + let () = msg_send![object, setAction: sel!(didClick)]; + let () = msg_send![object, setTarget: object]; + object + } + } + + #[sel(didClick)] + fn did_click(&mut self) { + self.element.click.fire(&()); + } + } +} + +struct Checkbox { + element: model::Checkbox, +} + +objc_class! { + impl Checkbox: NSButton { + #[sel(initWithFrame:)] + fn init_with_frame(&mut self, frame_rect: Rect) -> cocoa::id { + unsafe { + let object: cocoa::id = msg_send![super(self.instance, class!(NSButton)), initWithFrame: frame_rect.0]; + if object.is_null() { + return object; + } + let () = msg_send![object, setButtonType: cocoa::NSButtonTypeSwitch]; + if let Some(label) = &self.element.label { + let () = msg_send![object, setTitle: nsstring(label.as_str())]; + } + let () = msg_send![object, setAction: sel!(didClick:)]; + let () = msg_send![object, setTarget: object]; + + match &self.element.checked { + Property::Binding(s) => { + if *s.borrow() { + let () = msg_send![object, setState: NSControlStateValueOn]; + } + } + Property::ReadOnly(_) => (), + Property::Static(_) => (), + } + + object + } + } + + #[sel(didClick:)] + fn did_click(&mut self, button: Objc<Checkbox>) { + match &self.element.checked { + Property::Binding(s) => { + let state = unsafe { std::mem::transmute::<_, cocoa::NSButton>(button).state() }; + *s.borrow_mut() = state == NSControlStateValueOn; + } + Property::ReadOnly(_) => (), + Property::Static(_) => (), + } + } + } +} + +impl Checkbox { + pub fn into_button(self) -> cocoa::NSButton { + let obj = self.into_object(); + // # Safety + // NSButton is the superclass of Objc<Checkbox>. + unsafe { std::mem::transmute(obj.autorelease()) } + } +} + +struct TextView; + +objc_class! { + impl TextView: NSTextView /*<NSTextViewDelegate>*/ { + #[sel(initWithFrame:)] + fn init_with_frame(&mut self, frame_rect: Rect) -> cocoa::id { + unsafe { + let object: cocoa::id = msg_send![super(self.instance, class!(NSTextView)), initWithFrame: frame_rect.0]; + if object.is_null() { + return object; + } + let () = msg_send![object, setDelegate: self.instance]; + object + } + } + + #[sel(textView:doCommandBySelector:)] + fn do_command_by_selector(&mut self, text_view: Ptr<cocoa::NSTextView>, selector: runtime::Sel) -> runtime::BOOL { + let Ptr(text_view) = text_view; + // Make Tab/Backtab navigate to key views rather than inserting tabs in the text view. + // We can't use the `NSText` `fieldEditor` property to implement this behavior because + // that will disable the Enter key. + if selector == sel!(insertTab:) { + unsafe { text_view.window().selectNextKeyView_(text_view.0) }; + return runtime::YES; + } else if selector == sel!(insertBacktab:) { + unsafe { text_view.window().selectPreviousKeyView_(text_view.0) }; + return runtime::YES; + } + runtime::NO + } + } +} + +impl From<Objc<TextView>> for cocoa::NSTextView { + fn from(tv: Objc<TextView>) -> Self { + // # Safety + // NSTextView is the superclass of Objc<TextView>. + unsafe { std::mem::transmute(tv) } + } +} + +// For some reason the bindgen code for the nslayoutanchor subclasses doesn't have +// `Into<NSLayoutAnchor>`, so we add our own. +trait IntoNSLayoutAnchor { + fn into_layout_anchor(self) -> cocoa::NSLayoutAnchor; +} + +impl IntoNSLayoutAnchor for cocoa::NSLayoutXAxisAnchor { + fn into_layout_anchor(self) -> cocoa::NSLayoutAnchor { + // # Safety + // NSLayoutXAxisAnchor is a subclass of NSLayoutAnchor + cocoa::NSLayoutAnchor(self.0) + } +} + +impl IntoNSLayoutAnchor for cocoa::NSLayoutYAxisAnchor { + fn into_layout_anchor(self) -> cocoa::NSLayoutAnchor { + // # Safety + // NSLayoutYAxisAnchor is a subclass of NSLayoutAnchor + cocoa::NSLayoutAnchor(self.0) + } +} + +unsafe fn constraint_equal<T, O>(anchor: T, to: O) +where + T: INSLayoutAnchor<()> + std::ops::Deref, + T::Target: Message + Sized, + O: IntoNSLayoutAnchor, +{ + anchor + .constraintEqualToAnchor_(to.into_layout_anchor()) + .setActive_(runtime::YES); +} + +#[derive(Default)] +struct WindowRenderer { + windows_to_retain: Vec<StrongRef<cocoa::NSWindow>>, + rtl: bool, +} + +impl WindowRenderer { + pub fn unwrap(self) -> Vec<StrongRef<cocoa::NSWindow>> { + self.windows_to_retain + } + + pub fn render(&mut self, window: TypedElement<model::Window>) -> StrongRef<cocoa::NSWindow> { + let style = window.style; + let model::Window { + close, + children, + content, + modal, + title, + } = window.element_type; + + let w = Window { + modal, + title, + style, + } + .into_object(); + + let nswindow: StrongRef<cocoa::NSWindow> = w.clone().cast(); + + unsafe { + // Don't release windows when closed: we retain windows at the top-level. + nswindow.setReleasedWhenClosed_(runtime::NO); + + if let Some(close) = close { + let nswindow = nswindow.weak(); + close.subscribe(move |&()| { + if let Some(nswindow) = nswindow.lock() { + nswindow.close(); + } + }); + } + + if let Some(e) = content { + // Use an NSBox as a container view so that the window's content can easily have + // constraints set up relative to the parent (they can't be set relative to the + // window). + let content_parent: StrongRef<cocoa::NSBox> = msg_send![class!(NSBox), new]; + content_parent.setTitlePosition_(cocoa::NSNoTitle); + content_parent.setTransparent_(runtime::YES); + content_parent.setContentViewMargins_(cocoa::NSSize { + width: 5.0, + height: 5.0, + }); + if ViewRenderer::new_with_selector(self.rtl, *content_parent, sel!(setContentView:)) + .render(*e) + { + nswindow.setContentView_((*content_parent).into()); + } + } + + for child in children { + let modal = child.element_type.modal; + let visible = child.style.visible.clone(); + let child_window = self.render(child); + + #[derive(Clone, Copy)] + struct ShowChild { + modal: bool, + } + + impl ShowChild { + pub fn show(&self, parent: cocoa::NSWindow, child: cocoa::NSWindow) { + unsafe { + parent.addChildWindow_ordered_(child, cocoa::NSWindowAbove); + child.makeKeyAndOrderFront_(parent.0); + if self.modal { + // Run the modal from the main nsapp.run() loop to prevent binding + // updates from being nested (as this will block until the modal is + // stopped). + enqueue(move || { + let nsapp = cocoa::NSApplication::sharedApplication(); + nsapp.runModalForWindow_(child); + }); + } + } + } + } + + let show_child = ShowChild { modal }; + + match visible { + Property::Static(visible) => { + if visible { + show_child.show(*nswindow, *child_window); + } + } + Property::Binding(b) => { + let child = child_window.weak(); + let parent = nswindow.weak(); + b.on_change(move |visible| { + let Some((w, child_window)) = parent.lock().zip(child.lock()) else { + return; + }; + if *visible { + show_child.show(*w, *child_window); + } else { + child_window.close(); + } + }); + if *b.borrow() { + show_child.show(*nswindow, *child_window); + } + } + Property::ReadOnly(_) => panic!("window visibility cannot be ReadOnly"), + } + } + } + self.windows_to_retain.push(nswindow.clone()); + nswindow + } +} + +struct ViewRenderer { + parent: cocoa::NSView, + add_subview: Box<dyn Fn(cocoa::NSView, &model::ElementStyle, cocoa::NSView)>, + ignore_vertical: bool, + ignore_horizontal: bool, + rtl: bool, +} + +impl ViewRenderer { + /// add_subview should add the rendered child view. + pub fn new<F>(rtl: bool, parent: impl Into<cocoa::NSView>, add_subview: F) -> Self + where + F: Fn(cocoa::NSView, &model::ElementStyle, cocoa::NSView) + 'static, + { + ViewRenderer { + parent: parent.into(), + add_subview: Box::new(add_subview), + ignore_vertical: false, + ignore_horizontal: false, + rtl, + } + } + + /// add_subview should be the selector to call on the parent view to add the rendered child view. + pub fn new_with_selector( + rtl: bool, + parent: impl Into<cocoa::NSView>, + add_subview: runtime::Sel, + ) -> Self { + Self::new(rtl, parent, move |parent, _style, child| { + let () = unsafe { (*parent.0).send_message(add_subview, (child,)) }.unwrap(); + }) + } + + /// Ignore vertical layout settings when rendering views. + pub fn ignore_vertical(mut self, setting: bool) -> Self { + self.ignore_vertical = setting; + self + } + + /// Ignore horizontal layout settings when rendering views. + pub fn ignore_horizontal(mut self, setting: bool) -> Self { + self.ignore_horizontal = setting; + self + } + + /// Render the given element. + /// + /// Returns whether the element was rendered. + pub fn render( + &self, + Element { + style, + element_type, + }: Element, + ) -> bool { + let Some(view) = render_element(element_type, &style, self.rtl) else { + return false; + }; + + (self.add_subview)(self.parent, &style, view); + + // Setting the content hugging priority to a high value causes stackviews to not stretch + // subviews during autolayout. + unsafe { + view.setContentHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationHorizontal, + ); + view.setContentHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationVertical, + ); + } + + // Set layout and writing direction based on RTL. + unsafe { + view.setUserInterfaceLayoutDirection_(if self.rtl { + cocoa::NSUserInterfaceLayoutDirectionRightToLeft + } else { + cocoa::NSUserInterfaceLayoutDirectionLeftToRight + }); + if let Ok(control) = cocoa::NSControl::try_from(view) { + control.setBaseWritingDirection_(if self.rtl { + cocoa::NSWritingDirectionRightToLeft + } else { + cocoa::NSWritingDirectionLeftToRight + }); + } + } + + let lmg = unsafe { self.parent.layoutMarginsGuide() }; + + if !matches!(style.horizontal_alignment, Alignment::Fill) { + if let Some(size) = style.horizontal_size_request { + unsafe { + view.widthAnchor() + .constraintGreaterThanOrEqualToConstant_(size as _) + .setActive_(runtime::YES); + } + } + } + + if !self.ignore_horizontal { + unsafe { + let la = view.leadingAnchor(); + let ta = view.trailingAnchor(); + let pla = lmg.leadingAnchor(); + let pta = lmg.trailingAnchor(); + match style.horizontal_alignment { + Alignment::Fill => { + constraint_equal(la, pla); + constraint_equal(ta, pta); + // Without the autoresizing mask set, Text within Scroll doesn't display + // properly (it shrinks to 0-width, likely due to some specific interaction + // of NSScrollView with autolayout). + view.setAutoresizingMask_(cocoa::NSViewWidthSizable); + } + Alignment::Start => { + constraint_equal(la, pla); + } + Alignment::Center => { + let ca = view.centerXAnchor(); + let pca = lmg.centerXAnchor(); + constraint_equal(ca, pca); + } + Alignment::End => { + constraint_equal(ta, pta); + } + } + } + } + + if !matches!(style.vertical_alignment, Alignment::Fill) { + if let Some(size) = style.vertical_size_request { + unsafe { + view.heightAnchor() + .constraintGreaterThanOrEqualToConstant_(size as _) + .setActive_(runtime::YES); + } + } + } + + if !self.ignore_vertical { + unsafe { + let ta = view.topAnchor(); + let ba = view.bottomAnchor(); + let pta = lmg.topAnchor(); + let pba = lmg.bottomAnchor(); + match style.vertical_alignment { + Alignment::Fill => { + constraint_equal(ta, pta); + constraint_equal(ba, pba); + // Set the autoresizing mask to be consistent with the horizontal settings + // (see the comment there as to why it's necessary). + view.setAutoresizingMask_(cocoa::NSViewHeightSizable); + } + Alignment::Start => { + constraint_equal(ta, pta); + } + Alignment::Center => { + let ca = view.centerYAnchor(); + let pca = lmg.centerYAnchor(); + constraint_equal(ca, pca); + } + Alignment::End => { + constraint_equal(ba, pba); + } + } + } + } + + match &style.visible { + Property::Static(ref v) => { + unsafe { view.setHidden_((!v).into()) }; + } + Property::Binding(b) => { + b.on_change(move |&visible| unsafe { + view.setHidden_((!visible).into()); + }); + unsafe { view.setHidden_((!*b.borrow()).into()) }; + } + Property::ReadOnly(_) => { + unimplemented!("ElementStyle::visible doesn't support ReadOnly") + } + } + + if let Ok(control) = cocoa::NSControl::try_from(view) { + match &style.enabled { + Property::Static(e) => { + unsafe { control.setEnabled_((*e).into()) }; + } + Property::Binding(b) => { + b.on_change(move |&enabled| unsafe { + control.setEnabled_(enabled.into()); + }); + unsafe { control.setEnabled_((*b.borrow()).into()) }; + } + Property::ReadOnly(_) => { + unimplemented!("ElementStyle::enabled doesn't support ReadOnly") + } + } + } else if let Ok(text) = cocoa::NSText::try_from(view) { + let normally_editable = unsafe { text.isEditable() } == runtime::YES; + let normally_selectable = unsafe { text.isSelectable() } == runtime::YES; + let set_enabled = move |enabled: bool| unsafe { + if !enabled { + let mut range = text.selectedRange(); + range.length = 0; + text.setSelectedRange_(range); + } + text.setEditable_((enabled && normally_editable).into()); + text.setSelectable_((enabled && normally_selectable).into()); + text.setBackgroundColor_(if enabled { + cocoa::NSColor::textBackgroundColor() + } else { + cocoa::NSColor::windowBackgroundColor() + }); + text.setTextColor_(if enabled { + cocoa::NSColor::textColor() + } else { + cocoa::NSColor::disabledControlTextColor() + }); + }; + match &style.enabled { + Property::Static(e) => set_enabled(*e), + Property::Binding(b) => { + b.on_change(move |&enabled| set_enabled(enabled)); + set_enabled(*b.borrow()); + } + Property::ReadOnly(_) => { + unimplemented!("ElementStyle::enabled doesn't support ReadOnly") + } + } + } + + unsafe { view.setNeedsDisplay_(runtime::YES) }; + + true + } +} + +fn render_element( + element_type: model::ElementType, + style: &model::ElementStyle, + rtl: bool, +) -> Option<cocoa::NSView> { + use model::ElementType::*; + Some(match element_type { + VBox(model::VBox { items, spacing }) => { + let sv = unsafe { StrongRef::new(cocoa::NSStackView::alloc()) }.autorelease(); + unsafe { + sv.init(); + sv.setOrientation_(cocoa::NSUserInterfaceLayoutOrientationVertical); + sv.setAlignment_(cocoa::NSLayoutAttributeLeading); + sv.setSpacing_(spacing as _); + if style.vertical_alignment != Alignment::Fill { + // Make sure the vbox stays as small as its content. + sv.setHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationVertical, + ); + } + } + let renderer = ViewRenderer::new(rtl, sv, |parent, style, child| { + let gravity: cocoa::NSInteger = match style.vertical_alignment { + Alignment::Start | Alignment::Fill => 1, + Alignment::Center => 2, + Alignment::End => 3, + }; + let parent: cocoa::NSStackView = parent.try_into().unwrap(); + unsafe { parent.addView_inGravity_(child, gravity) }; + }) + .ignore_vertical(true); + for item in items { + renderer.render(item); + } + sv.into() + } + HBox(model::HBox { + mut items, + spacing, + affirmative_order, + }) => { + if affirmative_order { + items.reverse(); + } + let sv = unsafe { StrongRef::new(cocoa::NSStackView::alloc()) }.autorelease(); + unsafe { + sv.init(); + sv.setOrientation_(cocoa::NSUserInterfaceLayoutOrientationHorizontal); + sv.setAlignment_(cocoa::NSLayoutAttributeTop); + sv.setSpacing_(spacing as _); + if style.horizontal_alignment != Alignment::Fill { + // Make sure the hbox stays as small as its content. + sv.setHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationHorizontal, + ); + } + } + let renderer = ViewRenderer::new(rtl, sv, |parent, style, child| { + let gravity: cocoa::NSInteger = match style.horizontal_alignment { + Alignment::Start | Alignment::Fill => 1, + Alignment::Center => 2, + Alignment::End => 3, + }; + let parent: cocoa::NSStackView = parent.try_into().unwrap(); + unsafe { parent.addView_inGravity_(child, gravity) }; + }) + .ignore_horizontal(true); + for item in items { + renderer.render(item); + } + sv.into() + } + Button(mut b) => { + if let Some(Label(model::Label { + text: Property::Static(text), + .. + })) = b.content.take().map(|e| e.element_type) + { + let button = self::Button { element: b }.with_title(text.as_str()); + button.into() + } else { + return None; + } + } + Checkbox(cb) => { + let button = self::Checkbox { element: cb }.into_button(); + button.into() + } + Label(model::Label { text, bold }) => { + let tf = cocoa::NSTextField(unsafe { + cocoa::NSTextField::wrappingLabelWithString_(nsstring("")) + }); + unsafe { tf.setSelectable_(runtime::NO) }; + if bold { + unsafe { tf.setFont_(cocoa::NSFont::boldSystemFontOfSize_(0.0)) }; + } + match text { + Property::Static(text) => { + unsafe { tf.setStringValue_(nsstring(text.as_str())) }; + } + Property::Binding(b) => { + unsafe { tf.setStringValue_(nsstring(b.borrow().as_str())) }; + b.on_change(move |s| unsafe { tf.setStringValue_(nsstring(s)) }); + } + Property::ReadOnly(_) => unimplemented!("ReadOnly not supported for Label::text"), + } + tf.into() + } + Progress(model::Progress { amount }) => { + fn update(progress: cocoa::NSProgressIndicator, value: Option<f32>) { + unsafe { + match value { + None => { + progress.setIndeterminate_(runtime::YES); + progress.startAnimation_(progress.0); + } + Some(v) => { + progress.setDoubleValue_(v as f64); + progress.setIndeterminate_(runtime::NO); + } + } + } + } + + let progress = unsafe { StrongRef::new(cocoa::NSProgressIndicator::alloc()) }; + unsafe { + progress.init(); + progress.setMinValue_(0.0); + progress.setMaxValue_(1.0); + } + match amount { + Property::Static(v) => update(*progress, v), + Property::Binding(s) => { + update(*progress, *s.borrow()); + let weak = progress.weak(); + s.on_change(move |v| { + if let Some(r) = weak.lock() { + update(*r, *v); + } + }); + } + Property::ReadOnly(_) => (), + } + progress.autorelease().into() + } + Scroll(model::Scroll { content }) => { + let sv = unsafe { StrongRef::new(cocoa::NSScrollView::alloc()) }.autorelease(); + unsafe { + sv.init(); + sv.setHasVerticalScroller_(runtime::YES); + } + if let Some(content) = content { + ViewRenderer::new_with_selector(rtl, sv, sel!(setDocumentView:)) + .ignore_vertical(true) + .render(*content); + } + sv.into() + } + TextBox(model::TextBox { + placeholder, + content, + editable, + }) => { + let tv: StrongRef<cocoa::NSTextView> = TextView.into_object().cast(); + unsafe { + tv.setEditable_(editable.into()); + + cocoa::NSTextView_NSSharing::setAllowsUndo_(&*tv, runtime::YES); + tv.setVerticallyResizable_(runtime::YES); + if rtl { + let ps = StrongRef::new(cocoa::NSMutableParagraphStyle::alloc()); + ps.init(); + ps.setAlignment_(cocoa::NSTextAlignmentRight); + // We don't `use cocoa::NSTextView_NSSharing` because it has some methods which + // conflict with others that make it inconvenient. + cocoa::NSTextView_NSSharing::setDefaultParagraphStyle_(&*tv, (*ps).into()); + } + { + let container = tv.textContainer(); + container.setSize_(cocoa::NSSize { + width: f64::MAX, + height: f64::MAX, + }); + container.setWidthTracksTextView_(runtime::YES); + } + if let Some(placeholder) = placeholder { + // It's unclear why dictionaryWithObject_forKey_ takes `u64` rather than `id` + // arguments. + let attrs = cocoa::NSDictionary( + <cocoa::NSDictionary as NSDictionary_NSDictionaryCreation< + cocoa::NSAttributedStringKey, + cocoa::id, + >>::dictionaryWithObject_forKey_( + cocoa::NSColor::placeholderTextColor().0 as u64, + cocoa::NSForegroundColorAttributeName.0 as u64, + ), + ); + let string = StrongRef::new(cocoa::NSAttributedString( + cocoa::NSAttributedString::alloc() + .initWithString_attributes_(nsstring(placeholder.as_str()), attrs), + )); + // XXX: `setPlaceholderAttributedString` is undocumented (discovered at + // https://stackoverflow.com/a/43028577 and works identically to NSTextField), + // though hopefully it will be exposed in a public API some day. + tv.performSelector_withObject_(sel!(setPlaceholderAttributedString:), string.0); + } + } + match content { + Property::Static(s) => unsafe { tv.setString_(nsstring(s.as_str())) }, + Property::ReadOnly(od) => { + let weak = tv.weak(); + od.register(move |s| { + if let Some(tv) = weak.lock() { + *s = read_nsstring(unsafe { tv.string() }); + } + }); + } + Property::Binding(b) => { + let weak = tv.weak(); + b.on_change(move |s| { + if let Some(tv) = weak.lock() { + unsafe { tv.setString_(nsstring(s.as_str())) }; + } + }); + unsafe { tv.setString_(nsstring(b.borrow().as_str())) }; + } + } + tv.autorelease().into() + } + }) +} diff --git a/toolkit/crashreporter/client/app/src/ui/macos/objc.rs b/toolkit/crashreporter/client/app/src/ui/macos/objc.rs new file mode 100644 index 0000000000..d4f3f1c419 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/macos/objc.rs @@ -0,0 +1,242 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Objective-C bindings and helpers. + +// Forward all exports from the `objc` crate. +pub use objc::*; + +/// An objc class instance which contains rust data `T`. +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct Objc<T> { + pub instance: cocoa::id, + _phantom: std::marker::PhantomData<*mut T>, +} + +impl<T> Objc<T> { + pub fn new(instance: cocoa::id) -> Self { + Objc { + instance, + _phantom: std::marker::PhantomData, + } + } + + pub fn data(&self) -> &T { + let data = *unsafe { (*self.instance).get_ivar::<usize>("rust_self") } as *mut T; + unsafe { &*data } + } + + pub fn data_mut(&mut self) -> &mut T { + let data = *unsafe { (*self.instance).get_ivar::<usize>("rust_self") } as *mut T; + unsafe { &mut *data } + } +} + +impl<T> std::ops::Deref for Objc<T> { + type Target = T; + fn deref(&self) -> &Self::Target { + self.data() + } +} + +impl<T> std::ops::DerefMut for Objc<T> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.data_mut() + } +} + +unsafe impl<T> Encode for Objc<T> { + fn encode() -> Encoding { + cocoa::id::encode() + } +} + +/// Wrapper to provide `Encode` for bindgen-generated types (bindgen should do this in the future). +#[repr(transparent)] +pub struct Ptr<T>(pub T); + +unsafe impl<T> Encode for Ptr<T> { + fn encode() -> Encoding { + cocoa::id::encode() + } +} + +/// A strong objective-c reference to `T`. +#[repr(transparent)] +pub struct StrongRef<T> { + ptr: rc::StrongPtr, + _phantom: std::marker::PhantomData<T>, +} + +impl<T> Clone for StrongRef<T> { + fn clone(&self) -> Self { + StrongRef { + ptr: self.ptr.clone(), + _phantom: self._phantom, + } + } +} + +impl<T> std::ops::Deref for StrongRef<T> { + type Target = T; + fn deref(&self) -> &Self::Target { + let obj: &cocoa::id = &*self.ptr; + unsafe { std::mem::transmute(obj) } + } +} + +impl<T> StrongRef<T> { + /// Assume the given pointer-wrapper is an already-retained strong reference. + /// + /// # Safety + /// The type _must_ be the same size as cocoa::id and contain only a cocoa::id. + pub unsafe fn new(v: T) -> Self { + std::mem::transmute_copy(&v) + } + + /// Retain the given pointer-wrapper. + /// + /// # Safety + /// The type _must_ be the same size as cocoa::id and contain only a cocoa::id. + #[allow(dead_code)] + pub unsafe fn retain(v: T) -> Self { + let obj: cocoa::id = std::mem::transmute_copy(&v); + StrongRef { + ptr: rc::StrongPtr::retain(obj), + _phantom: std::marker::PhantomData, + } + } + + pub fn autorelease(self) -> T { + let obj = self.ptr.autorelease(); + unsafe { std::mem::transmute_copy(&obj) } + } + + pub fn weak(&self) -> WeakRef<T> { + WeakRef { + ptr: self.ptr.weak(), + _phantom: std::marker::PhantomData, + } + } + + /// Unwrap the StrongRef value without affecting reference counts. + /// + /// This is the opposite of `new`. + #[allow(dead_code)] + pub fn unwrap(self: Self) -> T { + let v = unsafe { std::mem::transmute_copy(&self) }; + std::mem::forget(self); + v + } + + /// Cast to a base class. + /// + /// Bindgen pointer-wrappers have trival `From<Derived> for Base` implementations. + pub fn cast<U: From<T>>(self) -> StrongRef<U> { + StrongRef { + ptr: self.ptr, + _phantom: std::marker::PhantomData, + } + } +} + +/// A weak objective-c reference to `T`. +#[derive(Clone)] +#[repr(transparent)] +pub struct WeakRef<T> { + ptr: rc::WeakPtr, + _phantom: std::marker::PhantomData<T>, +} + +impl<T> WeakRef<T> { + pub fn lock(&self) -> Option<StrongRef<T>> { + let ptr = self.ptr.load(); + if ptr.is_null() { + None + } else { + Some(StrongRef { + ptr, + _phantom: std::marker::PhantomData, + }) + } + } +} + +/// A macro for creating an objc class. +/// +/// Classes _must_ be registered before use (`Objc<T>::register()`). +/// +/// Example: +/// ``` +/// struct Foo(u8); +/// +/// objc_class! { +/// impl Foo: NSObject { +/// #[sel(mySelector:)] +/// fn my_selector(&mut self, arg: u8) -> u8 { +/// self.0 + arg +/// } +/// } +/// } +/// +/// fn make_foo() -> StrongRef<Objc<Foo>> { +/// Foo(42).into_object() +/// } +/// ``` +/// +/// Call `T::into_object()` to create the objective-c class instance. +macro_rules! objc_class { + ( impl $name:ident : $base:ident $(<$($protocol:ident),+>)? { + $( + #[sel($($sel:tt)+)] + fn $mname:ident (&mut $self:ident $(, $argname:ident : $argtype:ty )*) $(-> $rettype:ty)? $body:block + )* + }) => { + impl Objc<$name> { + pub fn register() { + let mut decl = declare::ClassDecl::new(concat!("CR", stringify!($name)), class!($base)).expect(concat!("failed to declare ", stringify!($name), " class")); + $($(decl.add_protocol(runtime::Protocol::get(stringify!($protocol)).expect(concat!("failed to find ",stringify!($protocol)," protocol")));)+)? + decl.add_ivar::<usize>("rust_self"); + $({ + extern fn method_impl(obj: &mut runtime::Object, _: runtime::Sel $(, $argname: $argtype )*) $(-> $rettype)? { + Objc::<$name>::new(obj).$mname($($argname),*) + } + unsafe { + decl.add_method(sel!($($sel)+), method_impl as extern fn(&mut runtime::Object, runtime::Sel $(, $argname: $argtype )*) $(-> $rettype)?); + } + })* + { + extern fn dealloc_impl(obj: &runtime::Object, _: runtime::Sel) { + drop(unsafe { Box::from_raw(*obj.get_ivar::<usize>("rust_self") as *mut $name) }); + unsafe { + let _: () = msg_send![super(obj, class!(NSObject)), dealloc]; + } + } + unsafe { + decl.add_method(sel!(dealloc), dealloc_impl as extern fn(&runtime::Object, runtime::Sel)); + } + } + decl.register(); + } + + pub fn class() -> &'static runtime::Class { + runtime::Class::get(concat!("CR", stringify!($name))).expect("class not registered") + } + + $(fn $mname (&mut $self $(, $argname : $argtype )*) $(-> $rettype)? $body)* + } + + impl $name { + pub fn into_object(self) -> StrongRef<Objc<$name>> { + let obj: *mut runtime::Object = unsafe { msg_send![Objc::<Self>::class(), alloc] }; + unsafe { (*obj).set_ivar("rust_self", Box::into_raw(Box::new(self)) as usize) }; + let obj: *mut runtime::Object = unsafe { msg_send![obj, init] }; + unsafe { StrongRef::new(Objc::new(obj)) } + } + } + } +} + +pub(crate) use objc_class; diff --git a/toolkit/crashreporter/client/app/src/ui/macos/plist.rs b/toolkit/crashreporter/client/app/src/ui/macos/plist.rs new file mode 100644 index 0000000000..a5bbe0aa0a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/macos/plist.rs @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! The embedded Info.plist file. + +const DATA: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + <key>CFBundleDisplayName</key> + <string>Crash Reporter</string> + <key>CFBundleExecutable</key> + <string>crashreporter</string> + <key>CFBundleIdentifier</key> + <string>org.mozilla.crashreporter</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>Crash Reporter</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleVersion</key> + <string>1.0</string> + <key>LSHasLocalizedDisplayName</key> + <true/> + <key>NSRequiresAquaSystemAppearance</key> + <false/> + <key>NSPrincipalClass</key> + <string>NSApplication</string> +</dict> +</plist>"#; + +const N: usize = DATA.len(); + +const PTR: *const [u8; N] = DATA.as_ptr() as *const [u8; N]; + +#[used] +#[link_section = "__TEXT,__info_plist"] +// # Safety +// The array pointer is created from `DATA` (a slice pointer) with `DATA.len()` as the length. +static PLIST: [u8; N] = unsafe { *PTR }; diff --git a/toolkit/crashreporter/client/app/src/ui/mod.rs b/toolkit/crashreporter/client/app/src/ui/mod.rs new file mode 100644 index 0000000000..8464b6a9b3 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/mod.rs @@ -0,0 +1,295 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! The UI model, UI implementations, and functions using them. +//! +//! UIs must implement: +//! * a `fn run_loop(&self, app: model::Application)` method which should display the UI and block while +//! handling events until the application terminates, +//! * a `fn invoke(&self, f: model::InvokeFn)` method which invokes the given function +//! asynchronously (without blocking) on the UI loop thread. + +use crate::std::{rc::Rc, sync::Arc}; +use crate::{ + async_task::AsyncTask, config::Config, data, logic::ReportCrash, settings::Settings, std, + thread_bound::ThreadBound, +}; +use model::{ui, Application}; +use ui_impl::UI; + +mod model; + +#[cfg(all(not(test), any(target_os = "linux", target_os = "windows")))] +mod icon { + // Must be DWORD-aligned for Win32 CreateIconFromResource. + #[repr(align(4))] + struct Aligned<Bytes: ?Sized>(Bytes); + static PNG_DATA_ALIGNMENT: &'static Aligned<[u8]> = + &Aligned(*include_bytes!("crashreporter.png")); + pub static PNG_DATA: &'static [u8] = &PNG_DATA_ALIGNMENT.0; +} + +#[cfg(test)] +pub mod test { + pub mod model { + pub use crate::ui::model::*; + } +} + +cfg_if::cfg_if! { + if #[cfg(test)] { + #[path = "test.rs"] + pub mod ui_impl; + } else if #[cfg(target_os = "linux")] { + #[path = "gtk.rs"] + mod ui_impl; + } else if #[cfg(target_os = "windows")] { + #[path = "windows/mod.rs"] + mod ui_impl; + } else if #[cfg(target_os = "macos")] { + #[path = "macos/mod.rs"] + mod ui_impl; + } else { + mod ui_impl { + #[derive(Default)] + pub struct UI; + + impl UI { + pub fn run_loop(&self, _app: super::model::Application) { + unimplemented!(); + } + + pub fn invoke(&self, _f: super::model::InvokeFn) { + unimplemented!(); + } + } + } + } +} + +/// Display an error dialog with the given message. +#[cfg_attr(mock, allow(unused))] +pub fn error_dialog<M: std::fmt::Display>(config: &Config, message: M) { + let close = data::Event::default(); + // Config may not have localized strings + let string_or = |name, fallback: &str| { + if config.strings.is_none() { + fallback.into() + } else { + config.string(name) + } + }; + + let details = if config.strings.is_none() { + format!("Details: {}", message) + } else { + config + .build_string("crashreporter-error-details") + .arg("details", message.to_string()) + .get() + }; + + let window = ui! { + Window title(string_or("crashreporter-branded-title", "Firefox Crash Reporter")) hsize(600) vsize(400) + close_when(&close) halign(Alignment::Fill) valign(Alignment::Fill) { + VBox margin(10) spacing(10) halign(Alignment::Fill) valign(Alignment::Fill) { + Label text(string_or( + "crashreporter-error", + "The application had a problem and crashed. \ + Unfortunately, the crash reporter is unable to submit a report for the crash." + )), + Label text(details), + Button["close"] halign(Alignment::End) on_click(move || close.fire(&())) { + Label text(string_or("crashreporter-button-close", "Close")) + } + } + } + }; + + UI::default().run_loop(Application { + windows: vec![window], + rtl: config.is_rtl(), + }); +} + +#[derive(Default, Debug, PartialEq, Eq)] +pub enum SubmitState { + #[default] + Initial, + InProgress, + Success, + Failure, +} + +/// The UI for the main crash reporter windows. +pub struct ReportCrashUI { + state: Arc<ThreadBound<ReportCrashUIState>>, + ui: Arc<UI>, + config: Arc<Config>, + logic: Rc<AsyncTask<ReportCrash>>, +} + +/// The state of the creash UI. +pub struct ReportCrashUIState { + pub send_report: data::Synchronized<bool>, + pub include_address: data::Synchronized<bool>, + pub show_details: data::Synchronized<bool>, + pub details: data::Synchronized<String>, + pub comment: data::OnDemand<String>, + pub submit_state: data::Synchronized<SubmitState>, + pub close_window: data::Event<()>, +} + +impl ReportCrashUI { + pub fn new( + initial_settings: &Settings, + config: Arc<Config>, + logic: AsyncTask<ReportCrash>, + ) -> Self { + let send_report = data::Synchronized::new(initial_settings.submit_report); + let include_address = data::Synchronized::new(initial_settings.include_url); + + ReportCrashUI { + state: Arc::new(ThreadBound::new(ReportCrashUIState { + send_report, + include_address, + show_details: Default::default(), + details: Default::default(), + comment: Default::default(), + submit_state: Default::default(), + close_window: Default::default(), + })), + ui: Default::default(), + config, + logic: Rc::new(logic), + } + } + + pub fn async_task(&self) -> AsyncTask<ReportCrashUIState> { + let state = self.state.clone(); + let ui = Arc::downgrade(&self.ui); + AsyncTask::new(move |f| { + let Some(ui) = ui.upgrade() else { return }; + ui.invoke(Box::new(cc! { (state) move || { + f(state.borrow()); + }})); + }) + } + + pub fn run(&self) { + let ReportCrashUI { + state, + ui, + config, + logic, + } = self; + let ReportCrashUIState { + send_report, + include_address, + show_details, + details, + comment, + submit_state, + close_window, + } = state.borrow(); + + send_report.on_change(cc! { (logic) move |v| { + let v = *v; + logic.push(move |s| s.settings.borrow_mut().submit_report = v); + }}); + include_address.on_change(cc! { (logic) move |v| { + let v = *v; + logic.push(move |s| s.settings.borrow_mut().include_url = v); + }}); + + let input_enabled = submit_state.mapped(|s| s == &SubmitState::Initial); + let send_report_and_input_enabled = + data::Synchronized::join(send_report, &input_enabled, |s, e| *s && *e); + + let submit_status_text = submit_state.mapped(cc! { (config) move |s| { + config.string(match s { + SubmitState::Initial => "crashreporter-submit-status", + SubmitState::InProgress => "crashreporter-submit-in-progress", + SubmitState::Success => "crashreporter-submit-success", + SubmitState::Failure => "crashreporter-submit-failure", + }) + }}); + + let progress_visible = submit_state.mapped(|s| s == &SubmitState::InProgress); + + let details_window = ui! { + Window["crash-details-window"] title(config.string("crashreporter-view-report-title")) + visible(show_details) modal(true) hsize(600) vsize(400) + halign(Alignment::Fill) valign(Alignment::Fill) + { + VBox margin(10) spacing(10) halign(Alignment::Fill) valign(Alignment::Fill) { + Scroll halign(Alignment::Fill) valign(Alignment::Fill) { + TextBox["details-text"] content(details) halign(Alignment::Fill) valign(Alignment::Fill) + }, + Button["close-details"] halign(Alignment::End) on_click(cc! { (show_details) move || *show_details.borrow_mut() = false }) { + Label text(config.string("crashreporter-button-ok")) + } + } + } + }; + + let main_window = ui! { + Window title(config.string("crashreporter-branded-title")) hsize(600) vsize(400) + halign(Alignment::Fill) valign(Alignment::Fill) close_when(close_window) + child_window(details_window) + { + VBox margin(10) spacing(10) halign(Alignment::Fill) valign(Alignment::Fill) { + Label text(config.string("crashreporter-apology")) bold(true), + Label text(config.string("crashreporter-crashed-and-restore")), + Label text(config.string("crashreporter-plea")), + Checkbox["send"] checked(send_report) label(config.string("crashreporter-send-report")) + enabled(&input_enabled), + VBox margin_start(20) spacing(5) halign(Alignment::Fill) valign(Alignment::Fill) { + Button["details"] enabled(&send_report_and_input_enabled) on_click(cc! { (config, details, show_details, logic) move || { + // Immediately display the window to feel responsive, even if forming + // the details string takes a little while (it really shouldn't + // though). + *details.borrow_mut() = config.string("crashreporter-loading-details"); + logic.push(|s| s.update_details()); + *show_details.borrow_mut() = true; + }}) + { + Label text(config.string("crashreporter-button-details")) + }, + Scroll halign(Alignment::Fill) valign(Alignment::Fill) { + TextBox["comment"] placeholder(config.string("crashreporter-comment-prompt")) + content(comment) + editable(true) + enabled(&send_report_and_input_enabled) + halign(Alignment::Fill) valign(Alignment::Fill) + }, + Checkbox["include-url"] checked(include_address) + label(config.string("crashreporter-include-url")) enabled(&send_report_and_input_enabled), + Label text(&submit_status_text) margin_top(20), + Progress halign(Alignment::Fill) visible(&progress_visible), + }, + HBox valign(Alignment::End) halign(Alignment::End) spacing(10) affirmative_order(true) + { + Button["restart"] visible(config.restart_command.is_some()) + on_click(cc! { (logic) move || logic.push(|s| s.restart()) }) + enabled(&input_enabled) hsize(160) + { + Label text(config.string("crashreporter-button-restart")) + }, + Button["quit"] on_click(cc! { (logic) move || logic.push(|s| s.quit()) }) + enabled(&input_enabled) hsize(160) + { + Label text(config.string("crashreporter-button-quit")) + } + } + } + } + }; + + ui.run_loop(Application { + windows: vec![main_window], + rtl: config.is_rtl(), + }); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/button.rs b/toolkit/crashreporter/client/app/src/ui/model/button.rs new file mode 100644 index 0000000000..d522fad6fc --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/button.rs @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::{Element, ElementBuilder}; +use crate::data::Event; + +/// A clickable button. +#[derive(Default, Debug)] +pub struct Button { + pub content: Option<Box<Element>>, + pub click: Event<()>, +} + +impl ElementBuilder<Button> { + pub fn on_click<F>(&mut self, f: F) + where + F: Fn() + 'static, + { + self.element_type.click.subscribe(move |_| f()); + } + + pub fn add_child(&mut self, child: Element) { + Self::single_child(&mut self.element_type.content, child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/checkbox.rs b/toolkit/crashreporter/client/app/src/ui/model/checkbox.rs new file mode 100644 index 0000000000..8923e33558 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/checkbox.rs @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::data::Property; + +/// A checkbox (with optional label). +#[derive(Default, Debug)] +pub struct Checkbox { + pub checked: Property<bool>, + pub label: Option<String>, +} + +impl super::ElementBuilder<Checkbox> { + pub fn checked(&mut self, value: impl Into<Property<bool>>) { + self.element_type.checked = value.into(); + } + + pub fn label<S: Into<String>>(&mut self, label: S) { + self.element_type.label = Some(label.into()); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/hbox.rs b/toolkit/crashreporter/client/app/src/ui/model/hbox.rs new file mode 100644 index 0000000000..b6c0e27e8c --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/hbox.rs @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::{Element, ElementBuilder}; + +/// A box which lays out contents horizontally. +#[derive(Default, Debug)] +pub struct HBox { + pub items: Vec<Element>, + pub spacing: u32, + pub affirmative_order: bool, +} + +impl ElementBuilder<HBox> { + pub fn spacing(&mut self, value: u32) { + self.element_type.spacing = value; + } + + /// Whether children are in affirmative order (and should be reordered based on platform + /// conventions). + /// + /// The children passed to `add_child` should be in most-affirmative to least-affirmative order + /// (e.g., "OK" then "Cancel" buttons). + /// + /// This is mainly useful for dialog buttons. + pub fn affirmative_order(&mut self, value: bool) { + self.element_type.affirmative_order = value; + } + + pub fn add_child(&mut self, child: Element) { + self.element_type.items.push(child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/label.rs b/toolkit/crashreporter/client/app/src/ui/model/label.rs new file mode 100644 index 0000000000..096ce022e3 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/label.rs @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::data::Property; + +/// A text label. +#[derive(Debug, Default)] +pub struct Label { + pub text: Property<String>, + pub bold: bool, +} + +impl super::ElementBuilder<Label> { + pub fn text(&mut self, s: impl Into<Property<String>>) { + self.element_type.text = s.into(); + } + + pub fn bold(&mut self, value: bool) { + self.element_type.bold = value; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/mod.rs b/toolkit/crashreporter/client/app/src/ui/model/mod.rs new file mode 100644 index 0000000000..5ea2ddc59a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/mod.rs @@ -0,0 +1,344 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! The UI model. +//! +//! Model elements should generally be declared as types with all fields `pub` (to be accessed by +//! UI implementations), though accessor methods are acceptable if needed. An +//! `ElementBuilder<TYPE>` impl should be provided to create methods that will be used in the +//! [`ui!`] macro. The model types are accessible when being _consumed_ by a UI implementation, +//! whereas the `ElementBuilder` types are accessible when the model is being _created_. +//! +//! All elements should be listed in the `element_types!` macro in this file (note that [`Window`], +//! while an element, isn't listed here as it cannot be a child element). This populates the +//! `ElementType` enum and generates `From<Element>` for `ElementType`, and `TryFrom<ElementType>` +//! for the element (as well as reference `TryFrom`). +//! +//! The model is written to accommodate layout and text direction differences (e.g. for RTL +//! languages), and UI implementations are expected to account for this correctly. + +use crate::data::Property; +pub use button::Button; +pub use checkbox::Checkbox; +pub use hbox::HBox; +pub use label::Label; +pub use progress::Progress; +pub use scroll::Scroll; +pub use textbox::TextBox; +pub use vbox::VBox; +pub use window::Window; + +mod button; +mod checkbox; +mod hbox; +mod label; +mod progress; +mod scroll; +mod textbox; +mod vbox; +mod window; + +/// A GUI element, including general style attributes and a more specific type. +/// +/// `From<ElementBuilder<...>>` is implemented for all elements listed in `element_types!`. +#[derive(Debug)] +pub struct Element { + pub style: ElementStyle, + pub element_type: ElementType, +} + +// This macro creates the `ElementType` enum and corresponding `From<ElementBuilder>` impls for +// Element. The `ElementType` discriminants match the element type names. +macro_rules! element_types { + ( $($name:ident),* ) => { + /// A type of GUI element. + #[derive(Debug)] + pub enum ElementType { + $($name($name)),* + } + + $( + impl From<$name> for ElementType { + fn from(e: $name) -> ElementType { + ElementType::$name(e) + } + } + + impl TryFrom<ElementType> for $name { + type Error = &'static str; + + fn try_from(et: ElementType) -> Result<Self, Self::Error> { + if let ElementType::$name(v) = et { + Ok(v) + } else { + Err(concat!("ElementType was not ", stringify!($name))) + } + } + } + + impl<'a> TryFrom<&'a ElementType> for &'a $name { + type Error = &'static str; + + fn try_from(et: &'a ElementType) -> Result<Self, Self::Error> { + if let ElementType::$name(v) = et { + Ok(v) + } else { + Err(concat!("ElementType was not ", stringify!($name))) + } + } + } + + impl From<ElementBuilder<$name>> for Element { + fn from(b: ElementBuilder<$name>) -> Self { + Element { + style: b.style, + element_type: b.element_type.into(), + } + } + } + )* + } +} +element_types! { + Button, Checkbox, HBox, Label, Progress, Scroll, TextBox, VBox +} + +/// Common element style values. +#[derive(Debug)] +pub struct ElementStyle { + pub horizontal_alignment: Alignment, + pub vertical_alignment: Alignment, + pub horizontal_size_request: Option<u32>, + pub vertical_size_request: Option<u32>, + pub margin: Margin, + pub visible: Property<bool>, + pub enabled: Property<bool>, + #[cfg(test)] + pub id: Option<String>, +} + +impl Default for ElementStyle { + fn default() -> Self { + ElementStyle { + horizontal_alignment: Default::default(), + vertical_alignment: Default::default(), + horizontal_size_request: Default::default(), + vertical_size_request: Default::default(), + margin: Default::default(), + visible: true.into(), + enabled: true.into(), + #[cfg(test)] + id: Default::default(), + } + } +} + +/// A builder for `Element`s. +/// +/// Each element should add an `impl ElementBuilder<TYPE>` to add methods to their builder. +#[derive(Debug, Default)] +pub struct ElementBuilder<T> { + pub style: ElementStyle, + pub element_type: T, +} + +impl<T> ElementBuilder<T> { + /// Set horizontal alignment. + pub fn halign(&mut self, alignment: Alignment) { + self.style.horizontal_alignment = alignment; + } + + /// Set vertical alignment. + pub fn valign(&mut self, alignment: Alignment) { + self.style.vertical_alignment = alignment; + } + + /// Set the horizontal size request. + pub fn hsize(&mut self, value: u32) { + assert!(value <= i32::MAX as u32); + self.style.horizontal_size_request = Some(value); + } + + /// Set the vertical size request. + pub fn vsize(&mut self, value: u32) { + assert!(value <= i32::MAX as u32); + self.style.vertical_size_request = Some(value); + } + + /// Set start margin. + pub fn margin_start(&mut self, amount: u32) { + self.style.margin.start = amount; + } + + /// Set end margin. + pub fn margin_end(&mut self, amount: u32) { + self.style.margin.end = amount; + } + + /// Set start and end margins. + pub fn margin_horizontal(&mut self, amount: u32) { + self.margin_start(amount); + self.margin_end(amount) + } + + /// Set top margin. + pub fn margin_top(&mut self, amount: u32) { + self.style.margin.top = amount; + } + + /// Set bottom margin. + pub fn margin_bottom(&mut self, amount: u32) { + self.style.margin.bottom = amount; + } + + /// Set top and bottom margins. + pub fn margin_vertical(&mut self, amount: u32) { + self.margin_top(amount); + self.margin_bottom(amount) + } + + /// Set all margins. + pub fn margin(&mut self, amount: u32) { + self.margin_horizontal(amount); + self.margin_vertical(amount) + } + + /// Set visibility. + pub fn visible(&mut self, value: impl Into<Property<bool>>) { + self.style.visible = value.into(); + } + + /// Set whether an element is enabled. + /// + /// This generally should enable/disable interaction with an element. + pub fn enabled(&mut self, value: impl Into<Property<bool>>) { + self.style.enabled = value.into(); + } + + /// Set the element identifier. + #[cfg(test)] + pub fn id(&mut self, value: impl Into<String>) { + self.style.id = Some(value.into()); + } + + /// Set the element identifier (stub). + #[cfg(not(test))] + pub fn id(&mut self, _value: impl Into<String>) {} + + fn single_child(slot: &mut Option<Box<Element>>, child: Element) { + if slot.replace(Box::new(child)).is_some() { + panic!("{} can only have one child", std::any::type_name::<T>()); + } + } +} + +/// A typed [`Element`]. +/// +/// This is useful for the [`ui!`] macro when a method should accept a specific element type, since +/// the macro always creates [`ElementBuilder<T>`](ElementBuilder) and ends with a `.into()` (and this implements +/// `From<ElementBuilder<T>>`). +#[derive(Debug, Default)] +pub struct TypedElement<T> { + pub style: ElementStyle, + pub element_type: T, +} + +impl<T> From<ElementBuilder<T>> for TypedElement<T> { + fn from(b: ElementBuilder<T>) -> Self { + TypedElement { + style: b.style, + element_type: b.element_type, + } + } +} + +/// The alignment of an element in one direction. +/// +/// Note that rather than `Left`/`Right`, this class has `Start`/`End` as it is meant to be +/// layout-direction-aware. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum Alignment { + /// Align to the start of the direction. + #[default] + Start, + /// Align to the center of the direction. + Center, + /// Align to the end of the direction. + End, + /// Fill all available space. + Fill, +} + +/// The margins of an element. +/// +/// These are RTL-aware: for instance, `start` is the left margin in left-to-right languages and +/// the right margin in right-to-left languages. +#[derive(Default, Debug)] +pub struct Margin { + pub start: u32, + pub end: u32, + pub top: u32, + pub bottom: u32, +} + +/// A macro to allow a convenient syntax for creating elements. +/// +/// The macro expects the following syntax: +/// ``` +/// ElementTypeName some_method(arg1, arg2) other_method() { +/// Child ..., +/// Child2 ... +/// } +/// ``` +/// +/// The type is wrapped in an `ElementBuilder`, and methods are called on this builder with a +/// mutable reference. This means that element types must implement Default and must implement +/// builder methods on `ElementBuilder<ElementTypeName>`. The children block is optional, and calls +/// `add_child(child: Element)` for each provided child (so implement this method if desired). +/// +/// For testing, a string identifier can be set on any element with a `["my_identifier"]` following +/// the element type. +macro_rules! ui { + ( $el:ident + $([ $id:literal ])? + $( $method:ident $methodargs:tt )* + $({ $($contents:tt)* })? + ) => { + { + #[allow(unused_imports)] + use $crate::ui::model::*; + let mut el: ElementBuilder<$el> = Default::default(); + $( el.id($id); )? + $( el.$method $methodargs ; )* + $( ui! { @children (el) $($contents)* } )? + el.into() + } + }; + ( @children ($parent:expr) ) => {}; + ( @children ($parent:expr) + $el:ident + $([ $id:literal ])? + $( $method:ident $methodargs:tt )* + $({ $($contents:tt)* })? + $(, $($rest:tt)* )? + ) => { + $parent.add_child(ui!( $el $([$id])? $( $method $methodargs )* $({ $($contents)* })? )); + $(ui!( @children ($parent) $($rest)* ))? + }; +} + +pub(crate) use ui; + +/// An application, defined as a set of windows. +/// +/// When all windows are closed, the application is considered complete (and loops should exit). +pub struct Application { + pub windows: Vec<TypedElement<Window>>, + /// Whether the text direction should be right-to-left. + pub rtl: bool, +} + +/// A function to be invoked in the UI loop. +pub type InvokeFn = Box<dyn FnOnce() + Send + 'static>; diff --git a/toolkit/crashreporter/client/app/src/ui/model/progress.rs b/toolkit/crashreporter/client/app/src/ui/model/progress.rs new file mode 100644 index 0000000000..f3e4e4bf77 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/progress.rs @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::data::Property; + +/// A progress indicator. +#[derive(Debug, Default)] +pub struct Progress { + /// Progress between 0 and 1, or None if indeterminate. + pub amount: Property<Option<f32>>, +} + +impl super::ElementBuilder<Progress> { + #[allow(dead_code)] + pub fn amount(&mut self, value: impl Into<Property<Option<f32>>>) { + self.element_type.amount = value.into(); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/scroll.rs b/toolkit/crashreporter/client/app/src/ui/model/scroll.rs new file mode 100644 index 0000000000..47efa4a81e --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/scroll.rs @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::{Element, ElementBuilder}; + +/// A scrollable region. +#[derive(Debug, Default)] +pub struct Scroll { + pub content: Option<Box<Element>>, +} + +impl ElementBuilder<Scroll> { + pub fn add_child(&mut self, child: Element) { + Self::single_child(&mut self.element_type.content, child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/textbox.rs b/toolkit/crashreporter/client/app/src/ui/model/textbox.rs new file mode 100644 index 0000000000..08cd9ca1bc --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/textbox.rs @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::data::Property; + +/// A text box. +#[derive(Debug, Default)] +pub struct TextBox { + pub placeholder: Option<String>, + pub content: Property<String>, + pub editable: bool, +} + +impl super::ElementBuilder<TextBox> { + pub fn placeholder(&mut self, text: impl Into<String>) { + self.element_type.placeholder = Some(text.into()); + } + + pub fn content(&mut self, value: impl Into<Property<String>>) { + self.element_type.content = value.into(); + } + + pub fn editable(&mut self, value: bool) { + self.element_type.editable = value; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/vbox.rs b/toolkit/crashreporter/client/app/src/ui/model/vbox.rs new file mode 100644 index 0000000000..6f1b09b1e2 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/vbox.rs @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::{Element, ElementBuilder}; + +/// A box which lays out contents vertically. +#[derive(Debug, Default)] +pub struct VBox { + pub items: Vec<Element>, + pub spacing: u32, +} + +impl ElementBuilder<VBox> { + pub fn spacing(&mut self, value: u32) { + self.element_type.spacing = value; + } + + pub fn add_child(&mut self, child: Element) { + self.element_type.items.push(child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/window.rs b/toolkit/crashreporter/client/app/src/ui/model/window.rs new file mode 100644 index 0000000000..b56071ca19 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/window.rs @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::{Element, ElementBuilder, TypedElement}; +use crate::data::Event; + +/// A window. +#[derive(Debug, Default)] +pub struct Window { + pub title: String, + /// The window content is the first element. + pub content: Option<Box<Element>>, + /// Logical child windows. + pub children: Vec<TypedElement<Self>>, + pub modal: bool, + pub close: Option<Event<()>>, +} + +impl ElementBuilder<Window> { + /// Set the window title. + pub fn title(&mut self, s: impl Into<String>) { + self.element_type.title = s.into(); + } + + /// Set whether the window is modal (blocking interaction with other windows when displayed). + pub fn modal(&mut self, value: bool) { + self.element_type.modal = value; + } + + /// Register an event to close the window. + pub fn close_when(&mut self, event: &Event<()>) { + self.element_type.close = Some(event.clone()); + } + + /// Add a window as a logical child of this one. + /// + /// Logical children are always displayed above their parents. + pub fn child_window(&mut self, window: TypedElement<Window>) { + self.element_type.children.push(window); + } + + pub fn add_child(&mut self, child: Element) { + Self::single_child(&mut self.element_type.content, child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/test.rs b/toolkit/crashreporter/client/app/src/ui/test.rs new file mode 100644 index 0000000000..db98c072da --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/test.rs @@ -0,0 +1,270 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! A renderer for use in tests, which doesn't actually render a GUI but allows programmatic +//! interaction. +//! +//! The [`ui!`](super::ui) macro supports labeling any element with a string identifier, which can +//! be used to access the element in this UI. +//! +//! The [`Interact`] hook must be created to interact with the test UI, before the UI is run and on +//! the same thread as the UI. +//! +//! See how this UI is used in [`crate::test`]. + +use super::model::{self, Application, Element}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicBool, AtomicU8, Ordering::Relaxed}, + mpsc, Arc, Condvar, Mutex, +}; + +thread_local! { + static INTERACT: RefCell<Option<Arc<State>>> = Default::default(); +} + +/// A test UI which allows access to the UI elements. +#[derive(Default)] +pub struct UI { + interface: Mutex<Option<UIInterface>>, +} + +impl UI { + pub fn run_loop(&self, app: Application) { + let (tx, rx) = mpsc::channel(); + let interface = UIInterface { work: tx }; + + let elements = id_elements(&app); + INTERACT.with(cc! { (interface) move |r| { + if let Some(state) = &*r.borrow() { + state.set_interface(interface); + } + }}); + *self.interface.lock().unwrap() = Some(interface.clone()); + + // Close the UI when the root windows are closed. + // Use a bitfield rather than a count in case the `close` event is fired multiple times. + assert!(app.windows.len() <= 8); + let mut windows = 0u8; + for i in 0..app.windows.len() { + windows |= 1 << i; + } + let windows = Arc::new(AtomicU8::new(windows)); + for (index, window) in app.windows.iter().enumerate() { + if let Some(c) = &window.element_type.close { + c.subscribe(cc! { (interface, windows) move |&()| { + let old = windows + .fetch_update(Relaxed, Relaxed, |x| Some(x & !(1u8 << index))) + .unwrap(); + if old == 1u8 << index { + interface.work.send(Command::Finish).unwrap(); + } + }}); + } else { + // No close event, so we must assume a closed state (and assume that _some_ window + // will have a close event registered so we don't drop the interface now). + windows + .fetch_update(Relaxed, Relaxed, |x| Some(x & !(1u8 << index))) + .unwrap(); + } + } + + while let Ok(f) = rx.recv() { + match f { + Command::Invoke(f) => f(), + Command::Interact(f) => f(&elements), + Command::Finish => break, + } + } + + *self.interface.lock().unwrap() = None; + INTERACT.with(|r| { + if let Some(state) = &*r.borrow() { + state.clear_interface(); + } + }); + } + + pub fn invoke(&self, f: model::InvokeFn) { + let guard = self.interface.lock().unwrap(); + if let Some(interface) = &*guard { + let _ = interface.work.send(Command::Invoke(f)); + } + } +} + +/// Test interaction hook. +#[derive(Clone)] +pub struct Interact { + state: Arc<State>, +} + +impl Interact { + /// Create an interaction hook for the test UI. + /// + /// This should be done before running the UI, and must be done on the same thread that + /// later runs it. + pub fn hook() -> Self { + let v = Interact { + state: Default::default(), + }; + { + let state = v.state.clone(); + INTERACT.with(move |r| *r.borrow_mut() = Some(state)); + } + v + } + + /// Wait for the render thread to be ready for interaction. + pub fn wait_for_ready(&self) { + let mut guard = self.state.interface.lock().unwrap(); + while guard.is_none() && !self.state.cancel.load(Relaxed) { + guard = self.state.waiting_for_interface.wait(guard).unwrap(); + } + } + + /// Cancel an Interact (which causes `wait_for_ready` to always return). + pub fn cancel(&self) { + self.state.cancel.store(true, Relaxed); + self.state.waiting_for_interface.notify_all(); + } + + /// Run the given function on the element with the given type and identity. + /// + /// Panics if either the id is missing or the type is incorrect. + pub fn element<'a, 'b, T: 'b, F, R>(&self, id: &'a str, f: F) -> R + where + &'b T: TryFrom<&'b model::ElementType>, + F: FnOnce(&model::ElementStyle, &T) -> R + Send + 'a, + R: Send + 'a, + { + self.interact(id, move |element: &IdElement| match element { + IdElement::Generic(e) => Some(f(&e.style, (&e.element_type).try_into().ok()?)), + IdElement::Window(_) => None, + }) + .expect("incorrect element type") + } + + /// Run the given function on the window with the given identity. + /// + /// Panics if the id is missing or the type is incorrect. + pub fn window<'a, F, R>(&self, id: &'a str, f: F) -> R + where + F: FnOnce(&model::ElementStyle, &model::Window) -> R + Send + 'a, + R: Send + 'a, + { + self.interact(id, move |element| match element { + IdElement::Window(e) => Some(f(&e.style, &e.element_type)), + IdElement::Generic(_) => None, + }) + .expect("incorrect element type") + } + + fn interact<'a, 'b, F, R>(&self, id: &'a str, f: F) -> R + where + F: FnOnce(&IdElement<'b>) -> R + Send + 'a, + R: Send + 'a, + { + let (send, recv) = std::sync::mpsc::sync_channel(0); + { + let f: Box<dyn FnOnce(&IdElements<'b>) + Send + 'a> = Box::new(move |elements| { + let _ = send.send(elements.get(id).map(f)); + }); + + // # Safety + // The function is run while `'a` is still valid (we wait here for it to complete). + let f: Box<dyn FnOnce(&IdElements) + Send + 'static> = + unsafe { std::mem::transmute(f) }; + + let guard = self.state.interface.lock().unwrap(); + let interface = guard.as_ref().expect("renderer is not running"); + let _ = interface.work.send(Command::Interact(f)); + } + recv.recv().unwrap().expect("failed to get element") + } +} + +#[derive(Clone)] +struct UIInterface { + work: mpsc::Sender<Command>, +} + +enum Command { + Invoke(Box<dyn FnOnce() + Send + 'static>), + Interact(Box<dyn FnOnce(&IdElements) + Send + 'static>), + Finish, +} + +enum IdElement<'a> { + Generic(&'a Element), + Window(&'a model::TypedElement<model::Window>), +} + +type IdElements<'a> = HashMap<String, IdElement<'a>>; + +#[derive(Default)] +struct State { + interface: Mutex<Option<UIInterface>>, + waiting_for_interface: Condvar, + cancel: AtomicBool, +} + +impl State { + /// Set the interface for the interaction client to use. + pub fn set_interface(&self, interface: UIInterface) { + *self.interface.lock().unwrap() = Some(interface); + self.waiting_for_interface.notify_all(); + } + + /// Clear the UI interface. + pub fn clear_interface(&self) { + *self.interface.lock().unwrap() = None; + } +} + +fn id_elements<'a>(app: &'a Application) -> IdElements<'a> { + let mut elements: IdElements<'a> = Default::default(); + + let mut windows_to_visit: Vec<_> = app.windows.iter().collect(); + + let mut to_visit: Vec<&'a Element> = Vec::new(); + while let Some(window) = windows_to_visit.pop() { + if let Some(id) = &window.style.id { + elements.insert(id.to_owned(), IdElement::Window(window)); + } + windows_to_visit.extend(&window.element_type.children); + + if let Some(content) = &window.element_type.content { + to_visit.push(content); + } + } + + while let Some(el) = to_visit.pop() { + if let Some(id) = &el.style.id { + elements.insert(id.to_owned(), IdElement::Generic(el)); + } + + use model::ElementType::*; + match &el.element_type { + Button(model::Button { + content: Some(content), + .. + }) + | Scroll(model::Scroll { + content: Some(content), + }) => { + to_visit.push(content); + } + VBox(model::VBox { items, .. }) | HBox(model::HBox { items, .. }) => { + for item in items { + to_visit.push(item) + } + } + _ => (), + } + } + + elements +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/font.rs b/toolkit/crashreporter/client/app/src/ui/windows/font.rs new file mode 100644 index 0000000000..3ec48316eb --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/font.rs @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use windows_sys::Win32::{Foundation::S_OK, Graphics::Gdi, UI::Controls}; + +/// Windows font handle (`HFONT`). +pub struct Font(Gdi::HFONT); + +impl Font { + /// Get the system theme caption font. + /// + /// Panics if the font cannot be retrieved. + pub fn caption() -> Self { + unsafe { + let mut font = std::mem::zeroed::<Gdi::LOGFONTW>(); + success!(hresult + Controls::GetThemeSysFont(0, Controls::TMT_CAPTIONFONT as i32, &mut font) + ); + Font(success!(pointer Gdi::CreateFontIndirectW(&font))) + } + } + + /// Get the system theme bold caption font. + /// + /// Returns `None` if the font cannot be retrieved. + pub fn caption_bold() -> Option<Self> { + unsafe { + let mut font = std::mem::zeroed::<Gdi::LOGFONTW>(); + if Controls::GetThemeSysFont(0, Controls::TMT_CAPTIONFONT as i32, &mut font) != S_OK { + return None; + } + font.lfWeight = Gdi::FW_BOLD as i32; + + let ptr = Gdi::CreateFontIndirectW(&font); + if ptr == 0 { + return None; + } + Some(Font(ptr)) + } + } +} + +impl std::ops::Deref for Font { + type Target = Gdi::HFONT; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Drop for Font { + fn drop(&mut self) { + unsafe { Gdi::DeleteObject(self.0 as _) }; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/gdi.rs b/toolkit/crashreporter/client/app/src/ui/windows/gdi.rs new file mode 100644 index 0000000000..89828987bc --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/gdi.rs @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! GDI helpers. + +use windows_sys::Win32::{ + Foundation::HWND, + Graphics::Gdi::{self, GDI_ERROR, HDC, HGDIOBJ}, +}; + +/// A GDI drawing context. +pub struct DC { + hwnd: HWND, + hdc: HDC, +} + +impl DC { + /// Create a new DC. + pub fn new(hwnd: HWND) -> Option<Self> { + let hdc = unsafe { Gdi::GetDC(hwnd) }; + (hdc != 0).then_some(DC { hwnd, hdc }) + } + + /// Call the given function with a gdi object selected. + pub fn with_object_selected<R>(&self, object: HGDIOBJ, f: impl FnOnce(HDC) -> R) -> Option<R> { + let old_object = unsafe { Gdi::SelectObject(self.hdc, object) }; + if old_object == 0 || old_object == GDI_ERROR as isize { + return None; + } + let ret = f(self.hdc); + // The prior object must be selected before releasing the DC. Ignore errors; this is + // best-effort. + unsafe { Gdi::SelectObject(self.hdc, old_object) }; + Some(ret) + } +} + +impl Drop for DC { + fn drop(&mut self) { + unsafe { Gdi::ReleaseDC(self.hwnd, self.hdc) }; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/layout.rs b/toolkit/crashreporter/client/app/src/ui/windows/layout.rs new file mode 100644 index 0000000000..7563b6b2f0 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/layout.rs @@ -0,0 +1,436 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Helpers for window layout. + +use super::{ + model::{self, Alignment, Element, ElementStyle, Margin}, + ElementRef, WideString, +}; +use crate::data::Property; +use std::collections::HashMap; +use windows_sys::Win32::{ + Foundation::{HWND, SIZE}, + Graphics::Gdi, + UI::WindowsAndMessaging as win, +}; + +pub(super) type ElementMapping = HashMap<ElementRef, HWND>; + +/// Handles the layout of windows. +/// +/// This is done in two passes. The first pass calculates the sizes for all elements in the tree. +/// Once sizes are known, the second pass can appropriately position the elements (taking alignment +/// into account). +/// +/// Currently, the resize/reposition logic is tied into the methods here, which is an inconvenient +/// design because when adding support for a new element type you have to add new information in +/// disparate locations. +pub struct Layout<'a> { + elements: &'a ElementMapping, + sizes: HashMap<ElementRef, Size>, + last_positioned: Option<HWND>, +} + +// Unfortunately, there's no good way to get these margins. I just guessed and the first guesses +// seemed to be close enough. +const BUTTON_MARGIN: Margin = Margin { + start: 5, + end: 5, + top: 5, + bottom: 5, +}; +const CHECKBOX_MARGIN: Margin = Margin { + start: 15, + end: 0, + top: 0, + bottom: 0, +}; + +impl<'a> Layout<'a> { + pub(super) fn new(elements: &'a ElementMapping) -> Self { + Layout { + elements, + sizes: Default::default(), + last_positioned: None, + } + } + + /// Perform a layout of the element and all child elements. + pub fn layout(mut self, element: &Element, max_width: u32, max_height: u32) { + let max_size = Size { + width: max_width, + height: max_height, + }; + self.resize(element, &max_size); + self.reposition(element, &Position::default(), &max_size); + } + + fn resize(&mut self, element: &Element, max_size: &Size) -> Size { + let style = &element.style; + + let mut inner_size = max_size.inner_size(style); + let mut content_size = None; + + if !is_visible(style) { + self.sizes + .insert(ElementRef::new(element), Default::default()); + return Default::default(); + } + + // Resize inner content. + // + // These cases should result in `content_size` being set, if relevant. + use model::ElementType::*; + match &element.element_type { + Button(model::Button { + content: Some(content), + .. + }) => { + // Special case for buttons with a label. + if let Label(model::Label { + text: Property::Static(text), + .. + }) = &content.element_type + { + let mut size = inner_size.less_margin(&BUTTON_MARGIN); + self.measure_text(text.as_str(), element, &mut size); + content_size = Some(size.plus_margin(&BUTTON_MARGIN)); + } + } + Checkbox(model::Checkbox { + label: Some(label), .. + }) => { + let mut size = inner_size.less_margin(&CHECKBOX_MARGIN); + self.measure_text(label.as_str(), element, &mut size); + content_size = Some(size.plus_margin(&CHECKBOX_MARGIN)); + } + Label(model::Label { text, bold: _ }) => { + let mut size = inner_size.clone(); + match text { + Property::Static(text) => self.measure_text(text.as_str(), element, &mut size), + Property::Binding(b) => { + self.measure_text(b.borrow().as_str(), element, &mut size) + } + Property::ReadOnly(_) => { + unimplemented!("Label::text does not support ReadOnly") + } + } + content_size = Some(size); + } + VBox(model::VBox { items, spacing }) => { + let mut height = 0; + let mut max_width = 0; + let mut remaining_size = inner_size.clone(); + let mut resize_child = |c| { + let child_size = self.resize(c, &remaining_size); + height += child_size.height; + max_width = std::cmp::max(child_size.width, max_width); + remaining_size.height = remaining_size + .height + .saturating_sub(child_size.height + spacing); + }; + // First resize all non-Fill items; Fill items get the remaining space. + for item in items + .iter() + .filter(|i| i.style.vertical_alignment != Alignment::Fill) + { + resize_child(item); + } + for item in items + .iter() + .filter(|i| i.style.vertical_alignment == Alignment::Fill) + { + resize_child(item); + } + content_size = Some(Size { + width: max_width, + height: height + spacing * (items.len().saturating_sub(1) as u32), + }); + } + HBox(model::HBox { + items, + spacing, + affirmative_order: _, + }) => { + let mut width = 0; + let mut max_height = 0; + let mut remaining_size = inner_size.clone(); + let mut resize_child = |c| { + let child_size = self.resize(c, &remaining_size); + width += child_size.width; + max_height = std::cmp::max(child_size.height, max_height); + remaining_size.width = remaining_size + .width + .saturating_sub(child_size.width + spacing); + }; + // First resize all non-Fill items; Fill items get the remaining space. + for item in items + .iter() + .filter(|i| i.style.horizontal_alignment != Alignment::Fill) + { + resize_child(item); + } + for item in items + .iter() + .filter(|i| i.style.horizontal_alignment == Alignment::Fill) + { + resize_child(item); + } + content_size = Some(Size { + width: width + spacing * (items.len().saturating_sub(1) as u32), + height: max_height, + }); + } + Scroll(model::Scroll { + content: Some(content), + }) => { + content_size = Some(self.resize(content, &inner_size)); + } + Progress(model::Progress { .. }) => { + // Min size recommended by windows uxguide + content_size = Some(Size { + width: 160, + height: 15, + }); + } + // We don't support sizing by textbox content yet (need to read from the HWND due to + // Property::ReadOnly). + TextBox(_) => (), + _ => (), + } + + // Adjust from content size. + if let Some(content_size) = content_size { + inner_size.from_content_size(style, &content_size); + } + + // Compute/store (outer) size and return. + let size = inner_size.plus_margin(&style.margin); + self.sizes.insert(ElementRef::new(element), size); + size + } + + fn get_size(&self, element: &Element) -> &Size { + self.sizes + .get(&ElementRef::new(element)) + .expect("element not resized") + } + + fn reposition(&mut self, element: &Element, position: &Position, parent_size: &Size) { + let style = &element.style; + if !is_visible(style) { + return; + } + let size = self.get_size(element); + + let start_offset = match style.horizontal_alignment { + Alignment::Fill | Alignment::Start => 0, + Alignment::Center => parent_size.width.saturating_sub(size.width) / 2, + Alignment::End => parent_size.width.saturating_sub(size.width), + }; + let top_offset = match style.vertical_alignment { + Alignment::Fill | Alignment::Start => 0, + Alignment::Center => parent_size.height.saturating_sub(size.height) / 2, + Alignment::End => parent_size.height.saturating_sub(size.height), + }; + + let inner_position = Position { + start: position.start + start_offset, + top: position.top + top_offset, + } + .less_margin(&style.margin); + let inner_size = size.less_margin(&style.margin); + + // Set the window size/position if there is a handle associated with the element. + if let Some(&hwnd) = self.elements.get(&ElementRef::new(element)) { + unsafe { + win::SetWindowPos( + hwnd, + self.last_positioned.unwrap_or(win::HWND_TOP), + inner_position.start.try_into().unwrap(), + inner_position.top.try_into().unwrap(), + inner_size.width.try_into().unwrap(), + inner_size.height.try_into().unwrap(), + 0, + ); + Gdi::InvalidateRect(hwnd, std::ptr::null(), 1); + } + self.last_positioned = Some(hwnd); + } + + // Reposition content. + match &element.element_type { + model::ElementType::VBox(model::VBox { items, spacing }) => { + let mut position = inner_position; + let mut size = inner_size; + for item in items { + self.reposition(item, &position, &size); + let consumed = self.get_size(item).height + spacing; + if item.style.vertical_alignment != Alignment::End { + position.top += consumed; + } + size.height = size.height.saturating_sub(consumed); + } + } + model::ElementType::HBox(model::HBox { + items, + spacing, + // The default ordering matches the windows platform order + affirmative_order: _, + }) => { + let mut position = inner_position; + let mut size = inner_size; + for item in items { + self.reposition(item, &position, &inner_size); + let consumed = self.get_size(item).width + spacing; + if item.style.horizontal_alignment != Alignment::End { + position.start += consumed; + } + size.width = size.width.saturating_sub(consumed); + } + } + model::ElementType::Scroll(model::Scroll { + content: Some(content), + }) => { + self.reposition(content, &inner_position, &inner_size); + } + _ => (), + } + } + + /// The `size` represents the maximum size permitted for the text (which is used for word + /// breaking), and it will be set to the precise width and height of the text. The width should + /// not exceed the input `size` width, but the height may. + fn measure_text(&mut self, text: &str, element: &Element, size: &mut Size) { + let Some(&window) = self.elements.get(&ElementRef::new(element)) else { + return; + }; + let hdc = unsafe { Gdi::GetDC(window) }; + unsafe { Gdi::SelectObject(hdc, win::SendMessageW(window, win::WM_GETFONT, 0, 0) as _) }; + let mut height: u32 = 0; + let mut max_width: u32 = 0; + let mut char_fit = 0i32; + let mut win_size = unsafe { std::mem::zeroed::<SIZE>() }; + for mut line in text.lines() { + if line.is_empty() { + line = " "; + } + let text = WideString::new(line); + let mut text = text.as_slice(); + let mut extents = vec![0i32; text.len()]; + while !text.is_empty() { + unsafe { + Gdi::GetTextExtentExPointW( + hdc, + text.as_ptr(), + text.len() as i32, + size.width.try_into().unwrap(), + &mut char_fit, + extents.as_mut_ptr(), + &mut win_size, + ); + } + if char_fit == 0 { + return; + } + let mut split = char_fit as usize; + let mut split_end = split.saturating_sub(1); + if (char_fit as usize) < text.len() { + for i in (0..char_fit as usize).rev() { + // FIXME safer utf16 handling? + if text[i] == b' ' as u16 { + split = i + 1; + split_end = i.saturating_sub(1); + break; + } + } + } + text = &text[split..]; + max_width = std::cmp::max(max_width, extents[split_end].try_into().unwrap()); + let measured_height: u32 = win_size.cy.try_into().unwrap(); + height += measured_height; + } + } + unsafe { Gdi::ReleaseDC(window, hdc) }; + + assert!(max_width <= size.width); + size.width = max_width; + size.height = height; + } +} + +#[derive(Debug, Default, Clone, Copy)] +struct Size { + pub width: u32, + pub height: u32, +} + +impl Size { + pub fn inner_size(&self, style: &ElementStyle) -> Self { + let mut ret = self.less_margin(&style.margin); + if let Some(width) = style.horizontal_size_request { + ret.width = width; + } + if let Some(height) = style.vertical_size_request { + ret.height = height; + } + ret + } + + pub fn from_content_size(&mut self, style: &ElementStyle, content_size: &Self) { + if style.horizontal_size_request.is_none() && style.horizontal_alignment != Alignment::Fill + { + self.width = content_size.width; + } + if style.vertical_size_request.is_none() && style.vertical_alignment != Alignment::Fill { + self.height = content_size.height; + } + } + + pub fn plus_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.width += margin.start + margin.end; + ret.height += margin.top + margin.bottom; + ret + } + + pub fn less_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.width = ret.width.saturating_sub(margin.start + margin.end); + ret.height = ret.height.saturating_sub(margin.top + margin.bottom); + ret + } +} + +#[derive(Debug, Default, Clone, Copy)] +struct Position { + pub start: u32, + pub top: u32, +} + +impl Position { + #[allow(dead_code)] + pub fn plus_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.start = ret.start.saturating_sub(margin.start); + ret.top = ret.top.saturating_sub(margin.top); + ret + } + + pub fn less_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.start += margin.start; + ret.top += margin.top; + ret + } +} + +fn is_visible(style: &ElementStyle) -> bool { + match &style.visible { + Property::Static(v) => *v, + Property::Binding(s) => *s.borrow(), + _ => true, + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/mod.rs b/toolkit/crashreporter/client/app/src/ui/windows/mod.rs new file mode 100644 index 0000000000..c2f396b80d --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/mod.rs @@ -0,0 +1,949 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! A UI using the windows API. +//! +//! This UI contains some edge cases that aren't implemented, for instance: +//! * there are a few cases where specific hierarchies are handled differently (e.g. a Button +//! containing Label, Scroll behavior, etc). +//! * not all controls handle all Property variants (e.g. Checkbox doesn't handle ReadOnly, TextBox +//! doesn't handle Binding, etc). +//! +//! The error handling is also a _little_ fast-and-loose, as many functions return an error value +//! that is acceptable to following logic (though it still would be a good idea to improve this). +//! +//! The rendering treats VBox, HBox, and Scroll as strictly layout-only: they do not create any +//! associated windows, and the layout logic handles their behavior. + +// Our windows-targets doesn't link uxtheme correctly for GetThemeSysFont/GetThemeSysColor. +// This was working in windows-sys 0.48. +#[link(name = "uxtheme", kind = "static")] +extern "C" {} + +use super::model::{self, Application, Element, ElementStyle, TypedElement}; +use crate::data::Property; +use font::Font; +use once_cell::sync::Lazy; +use quit_token::QuitToken; +use std::cell::RefCell; +use std::collections::HashMap; +use std::pin::Pin; +use std::rc::Rc; +use widestring::WideString; +use window::{CustomWindowClass, Window, WindowBuilder}; +use windows_sys::Win32::{ + Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}, + Graphics::Gdi, + System::{LibraryLoader::GetModuleHandleW, SystemServices, Threading::GetCurrentThreadId}, + UI::{Controls, Input::KeyboardAndMouse, Shell, WindowsAndMessaging as win}, +}; + +macro_rules! success { + ( nonzero $e:expr ) => {{ + let value = $e; + assert_ne!(value, 0); + value + }}; + ( lasterror $e:expr ) => {{ + unsafe { windows_sys::Win32::Foundation::SetLastError(0) }; + let value = $e; + assert!(value != 0 || windows_sys::Win32::Foundation::GetLastError() == 0); + value + }}; + ( hresult $e:expr ) => { + assert_eq!($e, windows_sys::Win32::Foundation::S_OK); + }; + ( pointer $e:expr ) => {{ + let ptr = $e; + assert_ne!(ptr, 0); + ptr + }}; +} + +mod font; +mod gdi; +mod layout; +mod quit_token; +mod twoway; +mod widestring; +mod window; + +/// A Windows API UI implementation. +pub struct UI { + thread_id: u32, +} + +/// Custom user messages. +#[repr(u32)] +enum UserMessage { + Invoke = win::WM_USER, +} + +fn get_invoke(msg: &win::MSG) -> Option<Box<model::InvokeFn>> { + if msg.message == UserMessage::Invoke as u32 { + Some(unsafe { Box::from_raw(msg.lParam as *mut model::InvokeFn) }) + } else { + None + } +} + +impl UI { + pub fn run_loop(&self, app: Application) { + // Initialize common controls. + { + let icc = Controls::INITCOMMONCONTROLSEX { + dwSize: std::mem::size_of::<Controls::INITCOMMONCONTROLSEX>() as _, + // Buttons, edit controls, and static controls are all included in 'standard'. + dwICC: Controls::ICC_STANDARD_CLASSES | Controls::ICC_PROGRESS_CLASS, + }; + success!(nonzero unsafe { Controls::InitCommonControlsEx(&icc) }); + } + + // Enable font smoothing (per + // https://learn.microsoft.com/en-us/windows/win32/gdi/cleartype-antialiasing ). + unsafe { + // We don't check for failure on these, they are best-effort. + win::SystemParametersInfoW( + win::SPI_SETFONTSMOOTHING, + 1, + std::ptr::null_mut(), + win::SPIF_UPDATEINIFILE | win::SPIF_SENDCHANGE, + ); + win::SystemParametersInfoW( + win::SPI_SETFONTSMOOTHINGTYPE, + 0, + win::FE_FONTSMOOTHINGCLEARTYPE as _, + win::SPIF_UPDATEINIFILE | win::SPIF_SENDCHANGE, + ); + } + + // Enable correct layout direction. + if unsafe { win::SetProcessDefaultLayout(if app.rtl { Gdi::LAYOUT_RTL } else { 0 }) } == 0 { + log::warn!("failed to set process layout direction"); + } + + let module: HINSTANCE = unsafe { GetModuleHandleW(std::ptr::null()) }; + + // Register custom classes. + AppWindow::register(module).expect("failed to register AppWindow window class"); + + { + // The quit token is cloned for each top-level window and dropped at the end of this + // scope. + let quit_token = QuitToken::new(); + + for window in app.windows { + let name = WideString::new(window.element_type.title.as_str()); + let w = top_level_window( + module, + AppWindow::new( + WindowRenderer::new(module, window.element_type, &window.style), + Some(quit_token.clone()), + ), + &name, + &window.style, + ); + + unsafe { win::ShowWindow(w.handle, win::SW_NORMAL) }; + unsafe { Gdi::UpdateWindow(w.handle) }; + } + } + + // Run the event loop. + let mut msg = unsafe { std::mem::zeroed::<win::MSG>() }; + while unsafe { win::GetMessageW(&mut msg, 0, 0, 0) } > 0 { + if let Some(f) = get_invoke(&msg) { + f(); + continue; + } + + unsafe { + // IsDialogMessageW is necessary to handle niceties like tab navigation + if win::IsDialogMessageW(win::GetAncestor(msg.hwnd, win::GA_ROOT), &mut msg) == 0 { + win::TranslateMessage(&msg); + win::DispatchMessageW(&msg); + } + } + } + + // Flush queue to properly drop late invokes (this is a very unlikely case) + while unsafe { win::PeekMessageW(&mut msg, 0, 0, 0, win::PM_REMOVE) } > 0 { + if let Some(f) = get_invoke(&msg) { + drop(f); + } + } + } + + pub fn invoke(&self, f: model::InvokeFn) { + let ptr: *mut model::InvokeFn = Box::into_raw(Box::new(f)); + if unsafe { + win::PostThreadMessageW(self.thread_id, UserMessage::Invoke as u32, 0, ptr as _) + } == 0 + { + let _ = unsafe { Box::from_raw(ptr) }; + log::warn!("failed to invoke function on thread message queue"); + } + } +} + +impl Default for UI { + fn default() -> Self { + UI { + thread_id: unsafe { GetCurrentThreadId() }, + } + } +} + +/// A reference to an Element. +#[derive(PartialEq, Eq, Hash, Clone, Copy)] +struct ElementRef(*const Element); + +impl ElementRef { + pub fn new(element: &Element) -> Self { + ElementRef(element as *const Element) + } + + /// # Safety + /// You must ensure the reference is still valid. + pub unsafe fn get(&self) -> &Element { + &*self.0 + } +} + +// Equivalent of win32 HIWORD macro +fn hiword(v: u32) -> u16 { + (v >> 16) as u16 +} + +// Equivalent of win32 LOWORD macro +fn loword(v: u32) -> u16 { + v as u16 +} + +// Equivalent of win32 MAKELONG macro +fn makelong(low: u16, high: u16) -> u32 { + (high as u32) << 16 | low as u32 +} + +fn top_level_window<W: window::WindowClass + window::WindowData>( + module: HINSTANCE, + class: W, + title: &WideString, + style: &ElementStyle, +) -> Window<W> { + class + .builder(module) + .name(title) + .style(win::WS_OVERLAPPEDWINDOW) + .pos(win::CW_USEDEFAULT, win::CW_USEDEFAULT) + .size( + style + .horizontal_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + style + .vertical_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + ) + .create() +} + +window::basic_window_classes! { + /// Static control (text, image, etc) class. + struct Static => "STATIC"; + + /// Button control class. + struct Button => "BUTTON"; + + /// Edit control class. + struct Edit => "EDIT"; + + /// Progress control class. + struct Progress => "msctls_progress32"; +} + +/// A top-level application window. +/// +/// This is used for the main window and modal windows. +struct AppWindow { + renderer: WindowRenderer, + _quit_token: Option<QuitToken>, +} + +impl AppWindow { + pub fn new(renderer: WindowRenderer, quit_token: Option<QuitToken>) -> Self { + AppWindow { + renderer, + _quit_token: quit_token, + } + } +} + +impl window::WindowClass for AppWindow { + fn class_name() -> WideString { + WideString::new("App Window") + } +} + +impl CustomWindowClass for AppWindow { + fn icon() -> win::HICON { + static ICON: Lazy<win::HICON> = Lazy::new(|| unsafe { + // If CreateIconFromResource fails it returns NULL, which is fine (a default icon will be + // used). + win::CreateIconFromResource( + // We take advantage of the fact that since Windows Vista, an RT_ICON resource entry + // can simply be a PNG image. + super::icon::PNG_DATA.as_ptr(), + super::icon::PNG_DATA.len() as u32, + true.into(), + // The 0x00030000 constant isn't available anywhere; the docs basically say to just + // pass it... + 0x00030000, + ) + }); + + *ICON + } + + fn message( + data: &RefCell<Self>, + hwnd: HWND, + umsg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> Option<LRESULT> { + let me = data.borrow(); + let model = me.renderer.model(); + match umsg { + win::WM_CREATE => { + if let Some(close) = &model.close { + close.subscribe(move |&()| unsafe { + win::SendMessageW(hwnd, win::WM_CLOSE, 0, 0); + }); + } + + let mut renderer = me.renderer.child_renderer(hwnd); + if let Some(child) = &model.content { + renderer.render_child(child); + } + + drop(model); + let children = std::mem::take(&mut me.renderer.model_mut().children); + for child in children { + renderer.render_window(child); + } + } + win::WM_CLOSE => { + if model.modal { + // Modal windows should hide themselves rather than closing/destroying. + unsafe { win::ShowWindow(hwnd, win::SW_HIDE) }; + return Some(0); + } + } + win::WM_SHOWWINDOW => { + if model.modal { + // Modal windows should disable/enable their parent as they are shown/hid, + // respectively. + let shown = wparam != 0; + unsafe { + KeyboardAndMouse::EnableWindow( + win::GetWindow(hwnd, win::GW_OWNER), + (!shown).into(), + ) + }; + return Some(0); + } + } + win::WM_GETMINMAXINFO => { + let minmaxinfo = unsafe { (lparam as *mut win::MINMAXINFO).as_mut().unwrap() }; + minmaxinfo.ptMinTrackSize.x = me.renderer.min_size.0.try_into().unwrap(); + minmaxinfo.ptMinTrackSize.y = me.renderer.min_size.1.try_into().unwrap(); + return Some(0); + } + win::WM_SIZE => { + // When resized, recompute the layout. + let width = loword(lparam as _) as u32; + let height = hiword(lparam as _) as u32; + + if let Some(child) = &model.content { + me.renderer.layout(child, width, height); + unsafe { Gdi::UpdateWindow(hwnd) }; + } + return Some(0); + } + win::WM_GETFONT => return Some(**me.renderer.font() as _), + win::WM_COMMAND => { + let child = lparam as HWND; + let windows = me.renderer.windows.borrow(); + if let Some(&element) = windows.reverse().get(&child) { + // # Safety + // The ElementRefs all pertain to the model stored in the renderer. + let element = unsafe { element.get() }; + // Handle button presses. + use model::ElementType::*; + match &element.element_type { + Button(model::Button { click, .. }) => { + let code = hiword(wparam as _) as u32; + if code == win::BN_CLICKED { + click.fire(&()); + return Some(0); + } + } + Checkbox(model::Checkbox { checked, .. }) => { + let code = hiword(wparam as _) as u32; + if code == win::BN_CLICKED { + let check_state = + unsafe { win::SendMessageW(child, win::BM_GETCHECK, 0, 0) }; + if let Property::Binding(s) = checked { + *s.borrow_mut() = check_state == Controls::BST_CHECKED as isize; + } + return Some(0); + } + } + _ => (), + } + } + } + _ => (), + } + None + } +} + +/// State used while creating and updating windows. +struct WindowRenderer { + // We wrap with an Rc to get weak references in property callbacks (like that of + // `ElementStyle::visible`). + inner: Rc<WindowRendererInner>, +} + +impl std::ops::Deref for WindowRenderer { + type Target = WindowRendererInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +struct WindowRendererInner { + pub module: HINSTANCE, + /// The model is pinned and boxed to ensure that references in `windows` remain valid. + /// + /// We need to keep the model around so we can correctly perform layout as the window size + /// changes. Unfortunately the win32 API doesn't have any nice ways to automatically perform + /// layout. + pub model: RefCell<Pin<Box<model::Window>>>, + pub min_size: (u32, u32), + /// Mapping between model elements and windows. + /// + /// Element references pertain to elements in `model`. + pub windows: RefCell<twoway::TwoWay<ElementRef, HWND>>, + pub font: Font, + pub bold_font: Font, +} + +impl WindowRenderer { + pub fn new(module: HINSTANCE, model: model::Window, style: &model::ElementStyle) -> Self { + WindowRenderer { + inner: Rc::new(WindowRendererInner { + module, + model: RefCell::new(Box::pin(model)), + min_size: ( + style.horizontal_size_request.unwrap_or(0), + style.vertical_size_request.unwrap_or(0), + ), + windows: Default::default(), + font: Font::caption(), + bold_font: Font::caption_bold().unwrap_or_else(Font::caption), + }), + } + } + + pub fn child_renderer(&self, window: HWND) -> WindowChildRenderer { + WindowChildRenderer { + renderer: &self.inner, + window, + child_id: 0, + scroll: false, + } + } + + pub fn layout(&self, element: &Element, max_width: u32, max_height: u32) { + layout::Layout::new(self.inner.windows.borrow().forward()) + .layout(element, max_width, max_height); + } + + pub fn model(&self) -> std::cell::Ref<'_, model::Window> { + std::cell::Ref::map(self.inner.model.borrow(), |b| &**b) + } + + pub fn model_mut(&self) -> std::cell::RefMut<'_, model::Window> { + std::cell::RefMut::map(self.inner.model.borrow_mut(), |b| &mut **b) + } + + pub fn font(&self) -> &Font { + &self.inner.font + } +} + +struct WindowChildRenderer<'a> { + renderer: &'a Rc<WindowRendererInner>, + window: HWND, + child_id: i32, + scroll: bool, +} + +impl<'a> WindowChildRenderer<'a> { + fn add_child<W: window::WindowClass>(&mut self, class: W) -> WindowBuilder<W> { + let builder = class + .builder(self.renderer.module) + .style(win::WS_CHILD | win::WS_VISIBLE) + .parent(self.window) + .child_id(self.child_id); + self.child_id += 1; + builder + } + + fn add_window<W: window::WindowClass>(&mut self, class: W) -> WindowBuilder<W> { + class + .builder(self.renderer.module) + .style(win::WS_OVERLAPPEDWINDOW) + .pos(win::CW_USEDEFAULT, win::CW_USEDEFAULT) + .parent(self.window) + } + + fn render_window(&mut self, model: TypedElement<model::Window>) -> Window { + let name = WideString::new(model.element_type.title.as_str()); + let style = model.style; + let w = self + .add_window(AppWindow::new( + WindowRenderer::new(self.renderer.module, model.element_type, &style), + None, + )) + .size( + style + .horizontal_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + style + .vertical_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + ) + .name(&name) + .create(); + + enabled_property(&style.enabled, w.handle); + + let hwnd = w.handle; + let set_visible = move |visible| unsafe { + win::ShowWindow(hwnd, if visible { win::SW_SHOW } else { win::SW_HIDE }); + }; + + match &style.visible { + Property::Static(false) => set_visible(false), + Property::Binding(s) => { + s.on_change(move |v| set_visible(*v)); + if !*s.borrow() { + set_visible(false); + } + } + _ => (), + } + + w.generic() + } + + fn render_child(&mut self, element: &Element) { + if let Some(mut window) = self.render_element_type(&element.element_type) { + window.set_default_font(&self.renderer.font); + + // Store the element to handle mapping. + self.renderer + .windows + .borrow_mut() + .insert(ElementRef::new(element), window.handle); + + enabled_property(&element.style.enabled, window.handle); + } + + // Handle visibility properties. + match &element.style.visible { + Property::Static(false) => { + set_visibility(element, false, self.renderer.windows.borrow().forward()) + } + Property::Binding(s) => { + let weak_renderer = Rc::downgrade(self.renderer); + let element_ref = ElementRef::new(element); + let parent = self.window; + s.on_change(move |visible| { + let Some(renderer) = weak_renderer.upgrade() else { + return; + }; + // # Safety + // ElementRefs are valid as long as the renderer is (and we have a strong + // reference to it). + let element = unsafe { element_ref.get() }; + set_visibility(element, *visible, renderer.windows.borrow().forward()); + // Send WM_SIZE so that the parent recomputes the layout. + unsafe { + let mut rect = std::mem::zeroed::<RECT>(); + win::GetClientRect(parent, &mut rect); + win::SendMessageW( + parent, + win::WM_SIZE, + 0, + makelong( + (rect.right - rect.left) as u16, + (rect.bottom - rect.top) as u16, + ) as isize, + ); + } + }); + if !*s.borrow() { + set_visibility(element, false, self.renderer.windows.borrow().forward()); + } + } + _ => (), + } + } + + fn render_element_type(&mut self, element_type: &model::ElementType) -> Option<Window> { + use model::ElementType as ET; + match element_type { + ET::Label(model::Label { text, bold }) => { + let mut window = match text { + Property::Static(text) => { + let text = WideString::new(text.as_str()); + self.add_child(Static) + .name(&text) + .add_style(SystemServices::SS_LEFT | SystemServices::SS_NOPREFIX) + .create() + } + Property::Binding(b) => { + let text = WideString::new(b.borrow().as_str()); + let window = self + .add_child(Static) + .name(&text) + .add_style(SystemServices::SS_LEFT | SystemServices::SS_NOPREFIX) + .create(); + let handle = window.handle; + b.on_change(move |text| { + let text = WideString::new(text.as_str()); + unsafe { win::SetWindowTextW(handle, text.pcwstr()) }; + }); + window + } + Property::ReadOnly(_) => { + unimplemented!("ReadOnly property not supported for Label::text") + } + }; + if *bold { + window.set_font(&self.renderer.bold_font); + } + Some(window.generic()) + } + ET::TextBox(model::TextBox { + placeholder, + content, + editable, + }) => { + let scroll = self.scroll; + let window = self + .add_child(Edit) + .add_style( + (win::ES_LEFT + | win::ES_MULTILINE + | win::ES_WANTRETURN + | if *editable { 0 } else { win::ES_READONLY }) + as u32 + | win::WS_BORDER + | win::WS_TABSTOP + | if scroll { win::WS_VSCROLL } else { 0 }, + ) + .create(); + + fn to_control_text(s: &str) -> String { + s.replace("\n", "\r\n") + } + + fn from_control_text(s: &str) -> String { + s.replace("\r\n", "\n") + } + + struct SubClassData { + placeholder: Option<WideString>, + } + + // EM_SETCUEBANNER doesn't work with multiline edit controls (for no particular + // reason?), so we have to draw it ourselves. + unsafe extern "system" fn subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _uidsubclass: usize, + dw_ref_data: usize, + ) -> LRESULT { + let ret = Shell::DefSubclassProc(hwnd, msg, wparam, lparam); + if msg == win::WM_PAINT + && KeyboardAndMouse::GetFocus() != hwnd + && win::GetWindowTextLengthW(hwnd) == 0 + { + let data = (dw_ref_data as *const SubClassData).as_ref().unwrap(); + if let Some(placeholder) = &data.placeholder { + let mut rect = std::mem::zeroed::<RECT>(); + win::GetClientRect(hwnd, &mut rect); + Gdi::InflateRect(&mut rect, -2, -2); + + let dc = gdi::DC::new(hwnd).expect("failed to create GDI DC"); + dc.with_object_selected( + win::SendMessageW(hwnd, win::WM_GETFONT, 0, 0) as _, + |hdc| { + Gdi::SetTextColor( + hdc, + Controls::GetThemeSysColor(0, Gdi::COLOR_GRAYTEXT), + ); + Gdi::SetBkMode(hdc, Gdi::TRANSPARENT as i32); + success!(nonzero Gdi::DrawTextW( + hdc, + placeholder.pcwstr(), + -1, + &mut rect, + Gdi::DT_LEFT | Gdi::DT_TOP | Gdi::DT_WORDBREAK, + )); + }, + ) + .expect("failed to select font gdi object"); + } + } + + // Multiline edit controls capture the tab key. We want it to work as usual in + // the dialog (focusing the next input control). + if msg == win::WM_GETDLGCODE && wparam == KeyboardAndMouse::VK_TAB as usize { + return 0; + } + + if msg == win::WM_DESTROY { + drop(unsafe { Box::from_raw(dw_ref_data as *mut SubClassData) }); + } + return ret; + } + + let subclassdata = Box::into_raw(Box::new(SubClassData { + placeholder: placeholder + .as_ref() + .map(|s| WideString::new(to_control_text(s))), + })); + + unsafe { + Shell::SetWindowSubclass( + window.handle, + Some(subclass_proc), + 0, + subclassdata as _, + ); + } + + // Set up content property. + match content { + Property::ReadOnly(od) => { + let handle = window.handle; + od.register(move |target| { + // GetWindowText requires the buffer be large enough for the terminating + // null character (otherwise it truncates the string), but + // GetWindowTextLength returns the length without the null character, so we + // add 1. + let length = unsafe { win::GetWindowTextLengthW(handle) } + 1; + let mut buf = vec![0u16; length as usize]; + unsafe { win::GetWindowTextW(handle, buf.as_mut_ptr(), length) }; + buf.pop(); // null character; `String` doesn't want that + *target = from_control_text(&String::from_utf16_lossy(&buf)); + }); + } + Property::Static(s) => { + let text = WideString::new(to_control_text(s)); + unsafe { win::SetWindowTextW(window.handle, text.pcwstr()) }; + } + Property::Binding(b) => { + let handle = window.handle; + b.on_change(move |text| { + let text = WideString::new(to_control_text(text.as_str())); + unsafe { win::SetWindowTextW(handle, text.pcwstr()) }; + }); + let text = WideString::new(to_control_text(b.borrow().as_str())); + unsafe { win::SetWindowTextW(window.handle, text.pcwstr()) }; + } + } + Some(window.generic()) + } + ET::Scroll(model::Scroll { content }) => { + if let Some(content) = content { + // Scrolling is implemented in a cooperative, non-universal way right now. + self.scroll = true; + self.render_child(content); + self.scroll = false; + } + None + } + ET::Button(model::Button { content, .. }) => { + if let Some(ET::Label(model::Label { + text: Property::Static(text), + .. + })) = content.as_ref().map(|e| &e.element_type) + { + let text = WideString::new(text); + + let window = self + .add_child(Button) + .add_style(win::BS_PUSHBUTTON as u32 | win::WS_TABSTOP) + .name(&text) + .create(); + Some(window.generic()) + } else { + None + } + } + ET::Checkbox(model::Checkbox { checked, label }) => { + let label = label.as_ref().map(WideString::new); + let mut builder = self + .add_child(Button) + .add_style((win::BS_AUTOCHECKBOX | win::BS_MULTILINE) as u32 | win::WS_TABSTOP); + if let Some(label) = &label { + builder = builder.name(label); + } + let window = builder.create(); + + fn set_check(handle: HWND, value: bool) { + unsafe { + win::SendMessageW( + handle, + win::BM_SETCHECK, + if value { + Controls::BST_CHECKED + } else { + Controls::BST_UNCHECKED + } as usize, + 0, + ); + } + } + + match checked { + Property::Static(checked) => set_check(window.handle, *checked), + Property::Binding(s) => { + let handle = window.handle; + s.on_change(move |v| { + set_check(handle, *v); + }); + set_check(window.handle, *s.borrow()); + } + _ => unimplemented!("ReadOnly properties not supported for Checkbox"), + } + + Some(window.generic()) + } + ET::Progress(model::Progress { amount }) => { + let window = self + .add_child(Progress) + .add_style(Controls::PBS_MARQUEE) + .create(); + + fn set_amount(handle: HWND, value: Option<f32>) { + match value { + None => unsafe { + win::SendMessageW(handle, Controls::PBM_SETMARQUEE, 1, 0); + }, + Some(v) => unsafe { + win::SendMessageW(handle, Controls::PBM_SETMARQUEE, 0, 0); + win::SendMessageW( + handle, + Controls::PBM_SETPOS, + (v.clamp(0f32, 1f32) * 100f32) as usize, + 0, + ); + }, + } + } + + match amount { + Property::Static(v) => set_amount(window.handle, *v), + Property::Binding(s) => { + let handle = window.handle; + s.on_change(move |v| set_amount(handle, *v)); + set_amount(window.handle, *s.borrow()); + } + _ => unimplemented!("ReadOnly properties not supported for Progress"), + } + + Some(window.generic()) + } + // VBox/HBox are virtual, their behaviors are implemented entirely in the renderer layout. + // No need for additional windows. + ET::VBox(model::VBox { items, .. }) => { + for item in items { + self.render_child(item); + } + None + } + ET::HBox(model::HBox { items, .. }) => { + for item in items { + self.render_child(item); + } + None + } + } + } +} + +/// Handle the enabled property. +/// +/// This function assumes the default state of the window is enabled. +fn enabled_property(enabled: &Property<bool>, window: HWND) { + match enabled { + Property::Static(false) => unsafe { + KeyboardAndMouse::EnableWindow(window, false.into()); + }, + Property::Binding(s) => { + let handle = window; + s.on_change(move |enabled| { + unsafe { KeyboardAndMouse::EnableWindow(handle, (*enabled).into()) }; + }); + if !*s.borrow() { + unsafe { KeyboardAndMouse::EnableWindow(window, false.into()) }; + } + } + _ => (), + } +} + +/// Set the visibility of the given element. This recurses down the element tree and hides children +/// as necessary. +fn set_visibility(element: &Element, visible: bool, windows: &HashMap<ElementRef, HWND>) { + if let Some(&hwnd) = windows.get(&ElementRef::new(element)) { + unsafe { + win::ShowWindow(hwnd, if visible { win::SW_SHOW } else { win::SW_HIDE }); + } + } else { + match &element.element_type { + model::ElementType::VBox(model::VBox { items, .. }) => { + for item in items { + set_visibility(item, visible, windows); + } + } + model::ElementType::HBox(model::HBox { items, .. }) => { + for item in items { + set_visibility(item, visible, windows); + } + } + model::ElementType::Scroll(model::Scroll { + content: Some(content), + }) => { + set_visibility(&*content, visible, windows); + } + _ => (), + } + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/quit_token.rs b/toolkit/crashreporter/client/app/src/ui/windows/quit_token.rs new file mode 100644 index 0000000000..f952db3db4 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/quit_token.rs @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::rc::Rc; +use windows_sys::Win32::UI::WindowsAndMessaging::PostQuitMessage; + +/// A Cloneable token which will post a quit message (with code 0) to the main loop when the last +/// instance is dropped. +#[derive(Clone, Default)] +pub struct QuitToken(#[allow(dead_code)] Rc<QuitTokenInternal>); + +impl QuitToken { + pub fn new() -> Self { + Self::default() + } +} + +impl std::fmt::Debug for QuitToken { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct(std::any::type_name::<Self>()) + .finish_non_exhaustive() + } +} + +#[derive(Default)] +struct QuitTokenInternal; + +impl Drop for QuitTokenInternal { + fn drop(&mut self) { + unsafe { PostQuitMessage(0) }; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/twoway.rs b/toolkit/crashreporter/client/app/src/ui/windows/twoway.rs new file mode 100644 index 0000000000..8a18162e08 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/twoway.rs @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::collections::HashMap; + +/// A two-way hashmap. +#[derive(Debug)] +pub struct TwoWay<K, V> { + forward: HashMap<K, V>, + reverse: HashMap<V, K>, +} + +impl<K, V> Default for TwoWay<K, V> { + fn default() -> Self { + TwoWay { + forward: Default::default(), + reverse: Default::default(), + } + } +} + +impl<K: Eq + std::hash::Hash + Clone, V: Eq + std::hash::Hash + Clone> TwoWay<K, V> { + pub fn insert(&mut self, key: K, value: V) { + self.forward.insert(key.clone(), value.clone()); + self.reverse.insert(value, key); + } + + pub fn forward(&self) -> &HashMap<K, V> { + &self.forward + } + + pub fn reverse(&self) -> &HashMap<V, K> { + &self.reverse + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/widestring.rs b/toolkit/crashreporter/client/app/src/ui/windows/widestring.rs new file mode 100644 index 0000000000..0dc713352b --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/widestring.rs @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use windows_sys::core::PCWSTR; + +/// Windows wide strings. +/// +/// These are utf16 encoded with a terminating null character (0). +pub struct WideString(Vec<u16>); + +impl WideString { + pub fn new(os_str: impl AsRef<OsStr>) -> Self { + // TODO: doesn't check whether the OsStr contains a null character, which could be treated + // as an error (as `CString::new` does). + WideString( + os_str + .as_ref() + .encode_wide() + .chain(std::iter::once(0)) + // Remove unicode BIDI markers (from fluent) which aren't rendered correctly. + .filter(|c| *c != 0x2068 && *c != 0x2069) + .collect(), + ) + } + + pub fn pcwstr(&self) -> PCWSTR { + self.0.as_ptr() + } + + pub fn as_slice(&self) -> &[u16] { + &self.0 + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/window.rs b/toolkit/crashreporter/client/app/src/ui/windows/window.rs new file mode 100644 index 0000000000..7e5e8f3f2a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/window.rs @@ -0,0 +1,302 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Types and helpers relating to windows and window classes. + +use super::Font; +use super::WideString; +use std::cell::RefCell; +use windows_sys::Win32::{ + Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, WPARAM}, + Graphics::Gdi::{self, HBRUSH}, + UI::WindowsAndMessaging::{self as win, HCURSOR, HICON}, +}; + +/// Types representing a window class. +pub trait WindowClass: Sized + 'static { + fn class_name() -> WideString; + + fn builder(self, module: HINSTANCE) -> WindowBuilder<'static, Self> { + WindowBuilder { + name: None, + style: None, + x: 0, + y: 0, + width: 0, + height: 0, + parent: None, + child_id: 0, + module, + data: self, + } + } +} + +/// Window classes which have their own message handler. +/// +/// A type implementing this trait provides its data to a single window. +/// +/// `register` must be called before use. +pub trait CustomWindowClass: WindowClass { + /// Handle a message. + fn message( + data: &RefCell<Self>, + hwnd: HWND, + umsg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> Option<LRESULT>; + + /// The class's default background brush. + fn background() -> HBRUSH { + (Gdi::COLOR_3DFACE + 1) as HBRUSH + } + + /// The class's default cursor. + fn cursor() -> HCURSOR { + unsafe { win::LoadCursorW(0, win::IDC_ARROW) } + } + + /// The class's default icon. + fn icon() -> HICON { + 0 + } + + /// Register the class. + fn register(module: HINSTANCE) -> anyhow::Result<()> { + unsafe extern "system" fn wnd_proc<W: CustomWindowClass>( + hwnd: HWND, + umsg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if umsg == win::WM_CREATE { + let create_struct = &*(lparam as *const win::CREATESTRUCTW); + success!(lasterror win::SetWindowLongPtrW(hwnd, 0, create_struct.lpCreateParams as _)); + // Changes made with SetWindowLongPtr don't take effect until SetWindowPos is called... + success!(nonzero win::SetWindowPos( + hwnd, + win::HWND_TOP, + 0, + 0, + 0, + 0, + win::SWP_NOMOVE | win::SWP_NOSIZE | win::SWP_NOZORDER | win::SWP_FRAMECHANGED, + )); + } + + let result = unsafe { W::get(hwnd).as_ref() } + .and_then(|data| W::message(data, hwnd, umsg, wparam, lparam)); + if umsg == win::WM_DESTROY { + drop(Box::from_raw( + win::GetWindowLongPtrW(hwnd, 0) as *mut RefCell<W> + )); + } + result.unwrap_or_else(|| win::DefWindowProcW(hwnd, umsg, wparam, lparam)) + } + + let class_name = Self::class_name(); + let window_class = win::WNDCLASSW { + lpfnWndProc: Some(wnd_proc::<Self>), + hInstance: module, + lpszClassName: class_name.pcwstr(), + hbrBackground: Self::background(), + hIcon: Self::icon(), + hCursor: Self::cursor(), + cbWndExtra: std::mem::size_of::<isize>() as i32, + ..unsafe { std::mem::zeroed() } + }; + + if unsafe { win::RegisterClassW(&window_class) } == 0 { + anyhow::bail!("RegisterClassW failed") + } + Ok(()) + } + + /// Get the window data from a window created with this class. + /// + /// # Safety + /// This must only be called on window handles which were created with this class. + unsafe fn get(hwnd: HWND) -> *const RefCell<Self> { + win::GetWindowLongPtrW(hwnd, 0) as *const RefCell<Self> + } +} + +/// Types that can be stored as associated window data. +pub trait WindowData: Sized { + fn to_ptr(self) -> *mut RefCell<Self> { + std::ptr::null_mut() + } +} + +impl<T: CustomWindowClass> WindowData for T { + fn to_ptr(self) -> *mut RefCell<Self> { + Box::into_raw(Box::new(RefCell::new(self))) + } +} + +macro_rules! basic_window_classes { + () => {}; + ( $(#[$attr:meta])* struct $name:ident => $class:expr; $($rest:tt)* ) => { + #[derive(Default)] + $(#[$attr])* + struct $name; + + impl $crate::ui::ui_impl::window::WindowClass for $name { + fn class_name() -> $crate::ui::ui_impl::WideString { + $crate::ui::ui_impl::WideString::new($class) + } + } + + impl $crate::ui::ui_impl::window::WindowData for $name {} + + $crate::ui::ui_impl::window::basic_window_classes!($($rest)*); + } +} + +pub(crate) use basic_window_classes; + +pub struct WindowBuilder<'a, W> { + name: Option<&'a WideString>, + style: Option<u32>, + x: i32, + y: i32, + width: i32, + height: i32, + parent: Option<HWND>, + child_id: i32, + module: HINSTANCE, + data: W, +} + +impl<'a, W> WindowBuilder<'a, W> { + #[must_use] + pub fn name<'b>(self, s: &'b WideString) -> WindowBuilder<'b, W> { + WindowBuilder { + name: Some(s), + style: self.style, + x: self.x, + y: self.y, + width: self.width, + height: self.height, + parent: self.parent, + child_id: self.child_id, + module: self.module, + data: self.data, + } + } + + #[must_use] + pub fn style(mut self, d: u32) -> Self { + self.style = Some(d); + self + } + + #[must_use] + pub fn add_style(mut self, d: u32) -> Self { + *self.style.get_or_insert(0) |= d; + self + } + + #[must_use] + pub fn pos(mut self, x: i32, y: i32) -> Self { + self.x = x; + self.y = y; + self + } + + #[must_use] + pub fn size(mut self, width: i32, height: i32) -> Self { + self.width = width; + self.height = height; + self + } + + #[must_use] + pub fn parent(mut self, parent: HWND) -> Self { + self.parent = Some(parent); + self + } + + #[must_use] + pub fn child_id(mut self, id: i32) -> Self { + self.child_id = id; + self + } + + pub fn create(self) -> Window<W> + where + W: WindowClass + WindowData, + { + let class_name = W::class_name(); + let handle = unsafe { + win::CreateWindowExW( + 0, + class_name.pcwstr(), + self.name.map(|n| n.pcwstr()).unwrap_or(std::ptr::null()), + self.style.unwrap_or_default(), + self.x, + self.y, + self.width, + self.height, + self.parent.unwrap_or_default(), + self.child_id as _, + self.module, + self.data.to_ptr() as _, + ) + }; + assert!(handle != 0); + + Window { + handle, + child_id: self.child_id, + font_set: false, + _class: std::marker::PhantomData, + } + } +} + +/// A window handle with a known class type. +/// +/// Without a type parameter (defaulting to `()`), the window handle is generic (class type +/// unknown). +pub struct Window<W: 'static = ()> { + pub handle: HWND, + pub child_id: i32, + font_set: bool, + _class: std::marker::PhantomData<&'static RefCell<W>>, +} + +impl<W: CustomWindowClass> Window<W> { + /// Get the window data of the window. + #[allow(dead_code)] + pub fn data(&self) -> &RefCell<W> { + unsafe { W::get(self.handle).as_ref().unwrap() } + } +} + +impl<W> Window<W> { + /// Get a generic window handle. + pub fn generic(self) -> Window { + Window { + handle: self.handle, + child_id: self.child_id, + font_set: self.font_set, + _class: std::marker::PhantomData, + } + } + + /// Set a window's font. + pub fn set_font(&mut self, font: &Font) { + unsafe { win::SendMessageW(self.handle, win::WM_SETFONT, **font as _, 1 as _) }; + self.font_set = true; + } + + /// Set a window's font if not already set. + pub fn set_default_font(&mut self, font: &Font) { + if !self.font_set { + self.set_font(font); + } + } +} |