From 1c313530e9ef97e86be40dab4f3a0f431459222a Mon Sep 17 00:00:00 2001
From: Alex Janka <alex@alexjanka.com>
Date: Mon, 30 Oct 2023 13:34:38 +1100
Subject: [PATCH] first pass preferences

---
 Cargo.lock                                    | 167 +++++++++++++++++-
 gb-emu/Cargo.toml                             |   8 +
 gb-emu/src/bin/gui.rs                         |   7 +
 gb-emu/src/bin/macos/mod.rs                   | 113 ++++++++++++
 gb-emu/src/bin/macos/preferences.rs           |  80 +++++++++
 gb-emu/src/bin/macos/preferences/toolbar.rs   | 109 ++++++++++++
 gb-emu/src/bin/macos/preferences/views.rs     |  65 +++++++
 .../bin/macos/preferences/views/widgets.rs    |  63 +++++++
 8 files changed, 604 insertions(+), 8 deletions(-)
 create mode 100644 gb-emu/src/bin/gui.rs
 create mode 100644 gb-emu/src/bin/macos/mod.rs
 create mode 100644 gb-emu/src/bin/macos/preferences.rs
 create mode 100644 gb-emu/src/bin/macos/preferences/toolbar.rs
 create mode 100644 gb-emu/src/bin/macos/preferences/views.rs
 create mode 100644 gb-emu/src/bin/macos/preferences/views/widgets.rs

diff --git a/Cargo.lock b/Cargo.lock
index e2d1bc1..1e45d3f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -371,6 +371,16 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "bitmask-enum"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49fb8528abca6895a5ada33d62aedd538a5c33e77068256483b44a3230270163"
+dependencies = [
+ "quote",
+ "syn 2.0.38",
+]
+
 [[package]]
 name = "blake3"
 version = "1.5.0"
@@ -439,6 +449,25 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
 
+[[package]]
+name = "cacao"
+version = "0.4.0-beta2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6de2bcb2324367ffb6ea53f977fab8234fa59e9a57e88f0f7be1ad7cb425c7b9"
+dependencies = [
+ "bitmask-enum",
+ "block",
+ "core-foundation 0.9.3",
+ "core-graphics 0.23.1",
+ "dispatch",
+ "lazy_static",
+ "libc",
+ "objc",
+ "objc_id",
+ "os_info",
+ "url",
+]
+
 [[package]]
 name = "cache-padded"
 version = "1.3.0"
@@ -593,7 +622,7 @@ dependencies = [
  "block",
  "core-foundation 0.7.0",
  "core-graphics 0.19.2",
- "foreign-types",
+ "foreign-types 0.3.2",
  "libc",
  "objc",
 ]
@@ -609,7 +638,7 @@ dependencies = [
  "cocoa-foundation",
  "core-foundation 0.9.3",
  "core-graphics 0.22.3",
- "foreign-types",
+ "foreign-types 0.3.2",
  "libc",
  "objc",
 ]
@@ -712,7 +741,7 @@ checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923"
 dependencies = [
  "bitflags 1.3.2",
  "core-foundation 0.7.0",
- "foreign-types",
+ "foreign-types 0.3.2",
  "libc",
 ]
 
@@ -725,7 +754,20 @@ dependencies = [
  "bitflags 1.3.2",
  "core-foundation 0.9.3",
  "core-graphics-types",
- "foreign-types",
+ "foreign-types 0.3.2",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "970a29baf4110c26fedbc7f82107d42c23f7e88e404c4577ed73fe99ff85a212"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.3",
+ "core-graphics-types",
+ "foreign-types 0.5.0",
  "libc",
 ]
 
@@ -1134,7 +1176,28 @@ version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
 dependencies = [
- "foreign-types-shared",
+ "foreign-types-shared 0.1.1",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared 0.3.1",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.38",
 ]
 
 [[package]]
@@ -1143,6 +1206,21 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
 
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652"
+dependencies = [
+ "percent-encoding",
+]
+
 [[package]]
 name = "futures"
 version = "0.3.29"
@@ -1246,6 +1324,7 @@ name = "gb-emu"
 version = "0.5.0"
 dependencies = [
  "bytemuck",
+ "cacao",
  "chrono",
  "clap",
  "cpal",
@@ -1255,6 +1334,7 @@ dependencies = [
  "gilrs",
  "image",
  "nokhwa",
+ "objc",
  "raw-window-handle",
  "send_wrapper",
  "serde",
@@ -1527,6 +1607,16 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
 
+[[package]]
+name = "idna"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
 [[package]]
 name = "image"
 version = "0.24.7"
@@ -1989,7 +2079,7 @@ dependencies = [
  "block",
  "cocoa 0.20.2",
  "core-graphics 0.19.2",
- "foreign-types",
+ "foreign-types 0.3.2",
  "log",
  "objc",
 ]
@@ -2003,7 +2093,7 @@ dependencies = [
  "bitflags 1.3.2",
  "block",
  "core-graphics-types",
- "foreign-types",
+ "foreign-types 0.3.2",
  "log",
  "objc",
 ]
@@ -2499,6 +2589,15 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
+]
+
 [[package]]
 name = "object"
 version = "0.32.1"
@@ -2552,6 +2651,17 @@ dependencies = [
  "redox_syscall 0.3.5",
 ]
 
+[[package]]
+name = "os_info"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "006e42d5b888366f1880eda20371fedde764ed2213dc8496f49622fa0c99cd5e"
+dependencies = [
+ "log",
+ "serde",
+ "winapi",
+]
+
 [[package]]
 name = "owned_ttf_parser"
 version = "0.19.0"
@@ -3391,6 +3501,21 @@ dependencies = [
  "strict-num",
 ]
 
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
 [[package]]
 name = "toml"
 version = "0.7.8"
@@ -3454,12 +3579,27 @@ dependencies = [
  "wide",
 ]
 
+[[package]]
+name = "unicode-bidi"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
 
+[[package]]
+name = "unicode-normalization"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+dependencies = [
+ "tinyvec",
+]
+
 [[package]]
 name = "unicode-segmentation"
 version = "1.10.1"
@@ -3478,6 +3618,17 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
 
+[[package]]
+name = "url"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
 [[package]]
 name = "utf8parse"
 version = "0.2.1"
@@ -3792,7 +3943,7 @@ dependencies = [
  "block",
  "core-graphics-types",
  "d3d12",
- "foreign-types",
+ "foreign-types 0.3.2",
  "fxhash",
  "glow",
  "gpu-alloc",
diff --git a/gb-emu/Cargo.toml b/gb-emu/Cargo.toml
index 84fa839..6877a17 100644
--- a/gb-emu/Cargo.toml
+++ b/gb-emu/Cargo.toml
@@ -6,6 +6,10 @@ description = "TWINC Game Boy (CGB/DMG) emulator"
 
 [package.metadata.bundle]
 identifier = "com.alexjanka.TWINC"
+[package.metadata.bundle.bin.cli]
+identifier = "com.alexjanka.TWINC.cli"
+[package.metadata.bundle.bin.gui]
+identifier = "com.alexjanka.TWINC.gui"
 
 [features]
 default = ["vulkan-static"]
@@ -36,3 +40,7 @@ serde = { version = "1.0", features = ["derive"] }
 image = { version = "0.24", default-features = false, features = ["png"] }
 bytemuck = "1.14"
 chrono = "0.4"
+
+[target.'cfg(any(target_os = "macos"))'.dependencies]
+cacao = "0.4.0-beta2"
+objc = "0.2"
diff --git a/gb-emu/src/bin/gui.rs b/gb-emu/src/bin/gui.rs
new file mode 100644
index 0000000..bfa18f2
--- /dev/null
+++ b/gb-emu/src/bin/gui.rs
@@ -0,0 +1,7 @@
+#[cfg(target_os = "macos")]
+mod macos;
+
+fn main() {
+    #[cfg(target_os = "macos")]
+    cacao::appkit::App::new("com.alexjanka.cacao-test", macos::TwincUiApp::default()).run();
+}
diff --git a/gb-emu/src/bin/macos/mod.rs b/gb-emu/src/bin/macos/mod.rs
new file mode 100644
index 0000000..71c3570
--- /dev/null
+++ b/gb-emu/src/bin/macos/mod.rs
@@ -0,0 +1,113 @@
+use cacao::appkit::menu::{Menu, MenuItem};
+use cacao::appkit::window::{Window, WindowConfig, WindowStyle, WindowToolbarStyle};
+use cacao::appkit::{App, AppDelegate};
+use cacao::notification_center::Dispatcher;
+
+use self::preferences::{PreferencesMessage, PreferencesUi};
+
+mod preferences;
+
+pub(crate) enum AppMessage {
+    Core(CoreMessage),
+    Preferences(PreferencesMessage),
+}
+
+pub(crate) enum CoreMessage {
+    Open,
+    OpenPreferences,
+}
+
+pub(crate) struct TwincUiApp {
+    preferences: Window<PreferencesUi>,
+}
+
+impl Default for TwincUiApp {
+    fn default() -> Self {
+        Self {
+            preferences: Window::with(
+                {
+                    let mut config = WindowConfig::default();
+                    config.set_initial_dimensions(100., 100., 400., 400.);
+
+                    config.set_styles(&[
+                        WindowStyle::Resizable,
+                        WindowStyle::Miniaturizable,
+                        WindowStyle::Closable,
+                        WindowStyle::Titled,
+                    ]);
+
+                    config.toolbar_style = WindowToolbarStyle::Preferences;
+                    config
+                },
+                PreferencesUi::new(),
+            ),
+        }
+    }
+}
+
+impl AppDelegate for TwincUiApp {
+    fn did_finish_launching(&self) {
+        App::set_menu(menu());
+        App::activate();
+    }
+}
+
+impl Dispatcher for TwincUiApp {
+    type Message = AppMessage;
+
+    fn on_ui_message(&self, message: Self::Message) {
+        match message {
+            AppMessage::Core(CoreMessage::Open) => println!("open"),
+            AppMessage::Core(CoreMessage::OpenPreferences) => {
+                self.preferences.show();
+            }
+            AppMessage::Preferences(prefs_message) => {
+                if let Some(delegate) = &self.preferences.delegate {
+                    delegate.message(prefs_message)
+                }
+            }
+        }
+    }
+}
+
+fn menu() -> Vec<Menu> {
+    vec![
+        Menu::new(
+            "",
+            vec![
+                MenuItem::About("Cacao Test".to_string()),
+                MenuItem::Separator,
+                MenuItem::new("Preferences")
+                    .key(",")
+                    .action(|| dispatch(AppMessage::Core(CoreMessage::OpenPreferences))),
+                MenuItem::Separator,
+                MenuItem::Services,
+                MenuItem::Separator,
+                MenuItem::Hide,
+                MenuItem::HideOthers,
+                MenuItem::ShowAll,
+                MenuItem::Separator,
+                MenuItem::Quit,
+            ],
+        ),
+        Menu::new(
+            "File",
+            vec![MenuItem::new("Open")
+                .key("o")
+                .action(|| dispatch(AppMessage::Core(CoreMessage::Open)))],
+        ),
+        Menu::new(
+            "Window",
+            vec![
+                MenuItem::Minimize,
+                MenuItem::Separator,
+                MenuItem::new("Bring All to Front"),
+            ],
+        ),
+        Menu::new("Help", vec![]),
+    ]
+}
+
+fn dispatch(message: AppMessage) {
+    App::<TwincUiApp, AppMessage>::dispatch_main(message);
+}
diff --git a/gb-emu/src/bin/macos/preferences.rs b/gb-emu/src/bin/macos/preferences.rs
new file mode 100644
index 0000000..811f2c5
--- /dev/null
+++ b/gb-emu/src/bin/macos/preferences.rs
@@ -0,0 +1,80 @@
+use cacao::{
+    appkit::{
+        toolbar::Toolbar,
+        window::{Window, WindowDelegate},
+    },
+    view::ViewController,
+};
+
+use self::{
+    toolbar::PreferencesToolbar,
+    views::{
+        CorePreferencesContentView, StandalonePreferencesContentView, VstPreferencesContentView,
+    },
+};
+
+mod toolbar;
+mod views;
+
+pub(crate) enum PreferencesMessage {
+    SwitchPane(PreferencesPane),
+}
+
+pub(crate) enum PreferencesPane {
+    Core,
+    Standalone,
+    Vst,
+}
+
+pub(crate) struct PreferencesUi {
+    pub(crate) toolbar: Toolbar<PreferencesToolbar>,
+    pub(crate) core_prefs: ViewController<CorePreferencesContentView>,
+    pub(crate) standalone_prefs: ViewController<StandalonePreferencesContentView>,
+    pub(crate) vst_prefs: ViewController<VstPreferencesContentView>,
+    window: Option<Window>,
+}
+
+impl PreferencesUi {
+    pub(crate) fn new() -> Self {
+        Self {
+            toolbar: Toolbar::new("PreferencesToolbar", PreferencesToolbar::default()),
+            core_prefs: ViewController::new(CorePreferencesContentView::default()),
+            standalone_prefs: ViewController::new(StandalonePreferencesContentView::default()),
+            vst_prefs: ViewController::new(VstPreferencesContentView::default()),
+            window: None,
+        }
+    }
+
+    pub(crate) fn message(&self, message: PreferencesMessage) {
+        let window = self.window.as_ref().unwrap();
+
+        match message {
+            PreferencesMessage::SwitchPane(PreferencesPane::Core) => {
+                window.set_title("Core");
+                window.set_content_view_controller(&self.core_prefs);
+            }
+            PreferencesMessage::SwitchPane(PreferencesPane::Standalone) => {
+                window.set_title("Standalone");
+                window.set_content_view_controller(&self.standalone_prefs);
+            }
+            PreferencesMessage::SwitchPane(PreferencesPane::Vst) => {
+                window.set_title("VST");
+                window.set_content_view_controller(&self.vst_prefs);
+            }
+        }
+    }
+}
+
+impl WindowDelegate for PreferencesUi {
+    const NAME: &'static str = "PreferencesUi";
+
+    fn did_load(&mut self, window: Window) {
+        window.set_autosave_name("PreferencesWindow");
+        window.set_movable_by_background(true);
+        window.set_toolbar(&self.toolbar);
+
+        self.window = Some(window);
+
+        self.message(PreferencesMessage::SwitchPane(PreferencesPane::Core));
+    }
+}
diff --git a/gb-emu/src/bin/macos/preferences/toolbar.rs b/gb-emu/src/bin/macos/preferences/toolbar.rs
new file mode 100644
index 0000000..af7293b
--- /dev/null
+++ b/gb-emu/src/bin/macos/preferences/toolbar.rs
@@ -0,0 +1,109 @@
+use cacao::{
+    appkit::toolbar::{ItemIdentifier, ToolbarDelegate, ToolbarItem},
+    image::{Image, MacSystemIcon},
+};
+
+use crate::macos::{dispatch, AppMessage};
+
+use super::{PreferencesMessage, PreferencesPane};
+
+pub(crate) struct PreferencesToolbar {
+    core: ToolbarItem,
+    standalone: ToolbarItem,
+    vst: ToolbarItem,
+}
+
+impl Default for PreferencesToolbar {
+    fn default() -> Self {
+        Self {
+            core: {
+                let mut item = ToolbarItem::new("core");
+                item.set_title("Core");
+
+                let icon = Image::toolbar_icon(MacSystemIcon::PreferencesGeneral, "Core");
+                item.set_image(icon);
+
+                item.set_action(|_| {
+                    dispatch(AppMessage::Preferences(PreferencesMessage::SwitchPane(
+                        PreferencesPane::Core,
+                    )));
+                });
+
+                item
+            },
+            standalone: {
+                let mut item = ToolbarItem::new("standalone");
+                item.set_title("Standalone");
+
+                let icon = Image::toolbar_icon(MacSystemIcon::PreferencesGeneral, "Standalone");
+                item.set_image(icon);
+
+                item.set_action(|_| {
+                    dispatch(AppMessage::Preferences(PreferencesMessage::SwitchPane(
+                        PreferencesPane::Standalone,
+                    )));
+                });
+
+                item
+            },
+            vst: {
+                let mut item = ToolbarItem::new("vst");
+                item.set_title("VST");
+
+                let icon = Image::toolbar_icon(MacSystemIcon::PreferencesGeneral, "VST");
+                item.set_image(icon);
+
+                item.set_action(|_| {
+                    dispatch(AppMessage::Preferences(PreferencesMessage::SwitchPane(
+                        PreferencesPane::Vst,
+                    )));
+                });
+
+                item
+            },
+        }
+    }
+}
+
+impl ToolbarDelegate for PreferencesToolbar {
+    const NAME: &'static str = "PreferencesToolbar";
+
+    fn did_load(&mut self, toolbar: cacao::appkit::toolbar::Toolbar) {
+        toolbar.set_selected("core")
+    }
+
+    fn allowed_item_identifiers(&self) -> Vec<cacao::appkit::toolbar::ItemIdentifier> {
+        vec![
+            ItemIdentifier::Custom("core"),
+            ItemIdentifier::Custom("standalone"),
+            ItemIdentifier::Custom("vst"),
+        ]
+    }
+
+    fn default_item_identifiers(&self) -> Vec<cacao::appkit::toolbar::ItemIdentifier> {
+        vec![
+            ItemIdentifier::Custom("core"),
+            ItemIdentifier::Custom("standalone"),
+            ItemIdentifier::Custom("vst"),
+        ]
+    }
+
+    fn selectable_item_identifiers(&self) -> Vec<ItemIdentifier> {
+        vec![
+            ItemIdentifier::Custom("core"),
+            ItemIdentifier::Custom("standalone"),
+            ItemIdentifier::Custom("vst"),
+        ]
+    }
+
+    fn item_for(&self, identifier: &str) -> &cacao::appkit::toolbar::ToolbarItem {
+        match identifier {
+            "core" => &self.core,
+            "standalone" => &self.standalone,
+            "vst" => &self.vst,
+            _ => {
+                unreachable!();
+            }
+        }
+    }
+}
diff --git a/gb-emu/src/bin/macos/preferences/views.rs b/gb-emu/src/bin/macos/preferences/views.rs
new file mode 100644
index 0000000..5dd13e2
--- /dev/null
+++ b/gb-emu/src/bin/macos/preferences/views.rs
@@ -0,0 +1,65 @@
+use cacao::{
+    layout::{Layout, LayoutConstraint},
+    view::{View, ViewDelegate},
+};
+
+use self::widgets::ToggleOptionView;
+
+mod widgets;
+
+#[derive(Default)]
+pub(crate) struct CorePreferencesContentView {
+    pub example_option: ToggleOptionView,
+}
+
+impl ViewDelegate for CorePreferencesContentView {
+    const NAME: &'static str = "CorePreferencesContentView";
+
+    fn did_load(&mut self, view: View) {
+        self.example_option.configure(
+            "An example preference",
+            "This can be true, or it can be false.",
+            false, // initial value
+            |_v| {},
+        );
+
+        view.add_subview(&self.example_option.view);
+
+        LayoutConstraint::activate(&[
+            self.example_option
+                .view
+                .top
+                .constraint_equal_to(&view.top)
+                .offset(22.),
+            self.example_option
+                .view
+                .leading
+                .constraint_equal_to(&view.leading)
+                .offset(22.),
+            self.example_option
+                .view
+                .trailing
+                .constraint_equal_to(&view.trailing)
+                .offset(-22.),
+            self.example_option
+                .view
+                .bottom
+                .constraint_equal_to(&view.bottom)
+                .offset(-22.),
+        ]);
+    }
+}
+
+#[derive(Default)]
+pub(crate) struct StandalonePreferencesContentView {}
+
+impl ViewDelegate for StandalonePreferencesContentView {
+    const NAME: &'static str = "StandalonePreferencesContentView";
+}
+
+#[derive(Default)]
+pub(crate) struct VstPreferencesContentView {}
+
+impl ViewDelegate for VstPreferencesContentView {
+    const NAME: &'static str = "VstPreferencesContentView";
+}
diff --git a/gb-emu/src/bin/macos/preferences/views/widgets.rs b/gb-emu/src/bin/macos/preferences/views/widgets.rs
new file mode 100644
index 0000000..57b88bf
--- /dev/null
+++ b/gb-emu/src/bin/macos/preferences/views/widgets.rs
@@ -0,0 +1,63 @@
+use cacao::layout::{Layout, LayoutConstraint};
+use cacao::switch::Switch;
+use cacao::text::Label;
+use cacao::view::View;
+use objc::runtime::Object;
+
+#[derive(Debug)]
+pub struct ToggleOptionView {
+    pub view: View,
+    pub switch: Switch,
+    pub title: Label,
+    pub subtitle: Label,
+}
+
+impl Default for ToggleOptionView {
+    fn default() -> Self {
+        let view = View::new();
+
+        let switch = Switch::new("");
+        view.add_subview(&switch);
+
+        let title = Label::new();
+        view.add_subview(&title);
+
+        let subtitle = Label::new();
+        view.add_subview(&subtitle);
+
+        LayoutConstraint::activate(&[
+            switch.top.constraint_equal_to(&view.top),
+            switch.leading.constraint_equal_to(&view.leading),
+            switch.width.constraint_equal_to_constant(24.),
+            title.top.constraint_equal_to(&view.top),
+            title.leading.constraint_equal_to(&switch.trailing),
+            title.trailing.constraint_equal_to(&view.trailing),
+            subtitle.top.constraint_equal_to(&title.bottom),
+            subtitle.leading.constraint_equal_to(&switch.trailing),
+            subtitle.trailing.constraint_equal_to(&view.trailing),
+            subtitle.bottom.constraint_equal_to(&view.bottom),
+            subtitle
+                .width
+                .constraint_greater_than_or_equal_to_constant(200.),
+        ]);
+
+        ToggleOptionView {
+            view,
+            switch,
+            title,
+            subtitle,
+        }
+    }
+}
+
+impl ToggleOptionView {
+    pub fn configure<F>(&mut self, text: &str, subtitle: &str, state: bool, handler: F)
+    where
+        F: Fn(*const Object) + Send + Sync + 'static,
+    {
+        self.title.set_text(text);
+        self.subtitle.set_text(subtitle);
+        self.switch.set_action(handler);
+        self.switch.set_checked(state);
+    }
+}