git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit64761df486e599e444b316f036bd5ea99b849601
parent235268c598
authorLucas Galante <[email protected]>
date2026-06-09 17:52
Implement ButtonStrip widget and integrate with MenuBar and Paginator

 src/widget/container/menu.rs      | 773 ++++++++++++++++++++++----------------
 src/widget/container/paginator.rs | 219 ++++++-----
 src/widget/input/button_strip.rs  | 225 +++++++++++
 src/widget/input/mod.rs           |   2 +
 src/widget/mod.rs                 |   6 +-
 5 files changed, 809 insertions(+), 416 deletions(-)

diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 0abf214..4e52ed3 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -5,16 +5,11 @@ use crate::widget::display::{make_widget_text_buffer, TextLabel};
 pub struct MenuBar {
     pub base: Plate,
     pub title: String,
-    pub menus: Vec<Box<Menu>>,
+    pub menus: ButtonStrip,
     pub menu_items: Vec<String>,
     pub vertical_items: Vec<String>,
     pub menu_dropdowns: Vec<Vec<String>>,
     pub menu_dropdown_checked: Vec<Vec<Option<bool>>>,
-    pub hovered_menu: Option<usize>,
-    pub open_menu: Option<usize>,
-    pub hovered_dropdown: Option<usize>,
-    pub clicked_dropdown: Option<(usize, usize)>,
-    pub was_open: Option<usize>,
     pub vertical: bool,
     pub focused: bool,
     pub z_level: i32,
@@ -24,16 +19,18 @@ pub struct MenuBar {
     pub curved_title_char_bufs: Vec<glyphon::Buffer>,
     pub font_family: String,
     pub label: Option<String>,
+    pub context_options: Vec<String>,
+    pub context_selected: usize,
+    pub context_dropdown_open: bool,
+    pub context_just_changed: bool,
+    pub context_hovered_item: Option<usize>,
+    pub context_title_hovered: bool,
+    pub context_item_bufs: Vec<glyphon::Buffer>,
 }
 
 impl MenuBar {
     pub fn set_curved_circle(&mut self, circle: Option<(f32, f32, f32)>) {
         self.base.curved_circle = circle;
-        if circle.is_none() {
-            for menu in &mut self.menus {
-                menu.curved_arc = None;
-            }
-        }
     }
 
     pub fn set_network_opacity(&mut self, opacity: f32) {
@@ -49,16 +46,11 @@ impl MenuBar {
         Self {
             base: base_plate,
             title: String::new(),
-            menus: Vec::new(),
+            menus: ButtonStrip::new(x, y, w, h),
             menu_items: Vec::new(),
             vertical_items: Vec::new(),
             menu_dropdowns: Vec::new(),
             menu_dropdown_checked: Vec::new(),
-            hovered_menu: None,
-            open_menu: None,
-            hovered_dropdown: None,
-            clicked_dropdown: None,
-            was_open: None,
             vertical: false,
             focused: false,
             z_level: 100,
@@ -68,6 +60,13 @@ impl MenuBar {
             curved_title_char_bufs: Vec::new(),
             font_family: crate::layout::menubar_font(),
             label: None,
+            context_options: Vec::new(),
+            context_selected: 0,
+            context_dropdown_open: false,
+            context_just_changed: false,
+            context_hovered_item: None,
+            context_title_hovered: false,
+            context_item_bufs: Vec::new(),
         }
     }
 
@@ -82,6 +81,108 @@ impl MenuBar {
         self.base.base.label = Some(label.to_string());
     }
 
+    pub fn with_context_options(mut self, options: Vec<String>, selected: usize) -> Self {
+        self.context_options = options;
+        self.context_selected = selected;
+        self
+    }
+
+    pub fn set_context_selected(&mut self, selected: usize) {
+        if self.context_selected != selected {
+            self.context_selected = selected;
+            self.context_item_bufs.clear();
+        }
+    }
+
+    pub fn take_context_change(&mut self) -> Option<usize> {
+        if self.context_just_changed {
+            self.context_just_changed = false;
+            Some(self.context_selected)
+        } else {
+            None
+        }
+    }
+
+    pub fn title_rect(&self) -> (f32, f32, f32, f32) {
+        if self.title.is_empty() {
+            return (0.0, 0.0, 0.0, 0.0);
+        }
+        let font_setting = crate::layout::menubar_font();
+        let (_, font_size_opt) = crate::layout::parse_font_string(&font_setting);
+        let font_size = font_size_opt.unwrap_or(12.0);
+        let char_w = 7.5 * (font_size / 12.0);
+        let padding_x = crate::layout::paginator_tab_padding_x();
+
+        let mut display_title = self.title.clone();
+        if !self.context_options.is_empty() {
+            display_title.push_str(" ▼");
+        }
+
+        if let Some((_ccx, _ccy, _ccr)) = self.base.curved_circle {
+            if let Some((tx, ty)) = self.title_pos {
+                let title_w = display_title.len() as f32 * char_w + 24.0;
+                (tx, ty, title_w, self.base.base.h)
+            } else {
+                (self.base.base.x, self.base.base.y, display_title.len() as f32 * char_w + 24.0, self.base.base.h)
+            }
+        } else if self.vertical {
+            let mut cy = 8.0;
+            if let Some(ref label) = self.label {
+                let line_height = font_size * 1.2;
+                let label_h = label.chars().count() as f32 * line_height;
+                cy += label_h + 8.0;
+            }
+            let line_height = font_size * 1.2;
+            let mut display_title_vertical = self.title.clone();
+            if !self.context_options.is_empty() {
+                display_title_vertical.push_str("▼");
+            }
+            let title_h = display_title_vertical.chars().count() as f32 * line_height;
+            (self.base.base.x, self.base.base.y + cy, self.base.base.w, title_h)
+        } else {
+            let mut start_x = 8.0;
+            if self.center_items {
+                let mut total_width = 8.0;
+                total_width += display_title.len() as f32 * char_w + 24.0;
+                for btn_label in &self.menus.buttons {
+                    total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
+                }
+                if self.base.base.w > total_width {
+                    start_x = (self.base.base.w - total_width) / 2.0;
+                }
+            }
+            let title_w = display_title.len() as f32 * char_w + 24.0;
+            (self.base.base.x + start_x, self.base.base.y, title_w, self.base.base.h)
+        }
+    }
+
+    pub fn context_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
+        if self.context_options.is_empty() || !self.context_dropdown_open {
+            return None;
+        }
+        let font_setting = crate::layout::menubar_font();
+        let (_, font_size_opt) = crate::layout::parse_font_string(&font_setting);
+        let font_size = font_size_opt.unwrap_or(12.0);
+        let char_w = 7.5 * (font_size / 12.0);
+
+        let max_len = self.context_options.iter().map(|s| s.len()).max().unwrap_or(0);
+        let dw = (max_len as f32 * char_w + 40.0).max(140.0);
+        let dh = self.context_options.len() as f32 * DROPDOWN_ITEM_H;
+
+        let tr = self.title_rect();
+        let dx = if self.vertical {
+            tr.0 + tr.2
+        } else {
+            tr.0
+        };
+        let dy = if self.vertical {
+            tr.1
+        } else {
+            tr.1 + tr.3
+        };
+        Some((dx, dy, dw, dh))
+    }
+
     pub fn with_center_items(mut self, center: bool) -> Self {
         self.center_items = center;
         self
@@ -97,11 +198,7 @@ impl MenuBar {
         self.vertical_items.push(label.to_string());
         self.menu_dropdowns.push(items.iter().map(|s| s.to_string()).collect());
         self.menu_dropdown_checked.push(vec![None; items.len()]);
-
-        let item_strs: Vec<String> = items.iter().map(|s| s.to_string()).collect();
-        let mut menu = Menu::new(label, label, &item_strs);
-        menu.vertical = self.vertical;
-        self.menus.push(Box::new(menu));
+        self.menus.add_button(label);
         self
     }
 
@@ -110,19 +207,24 @@ impl MenuBar {
         self.vertical_items.push(vertical_label.to_string());
         self.menu_dropdowns.push(items.iter().map(|s| s.to_string()).collect());
         self.menu_dropdown_checked.push(vec![None; items.len()]);
-
-        let item_strs: Vec<String> = items.iter().map(|s| s.to_string()).collect();
-        let mut menu = Menu::new(horizontal_label, vertical_label, &item_strs);
-        menu.vertical = self.vertical;
-        self.menus.push(Box::new(menu));
+        let label = if self.vertical { vertical_label } else { horizontal_label };
+        self.menus.add_button(label);
         self
     }
 
+    fn update_menu_labels(&mut self) {
+        let src = if self.vertical {
+            &self.vertical_items
+        } else {
+            &self.menu_items
+        };
+        self.menus.buttons = src.clone();
+    }
+
     pub fn with_vertical(mut self, vertical: bool) -> Self {
         self.vertical = vertical;
-        for menu in &mut self.menus {
-            menu.vertical = vertical;
-        }
+        self.menus.vertical = vertical;
+        self.update_menu_labels();
         self
     }
 
@@ -130,38 +232,6 @@ impl MenuBar {
         self.z_level = z;
         self
     }
-
-    fn item_h_vertical(&self, idx: usize) -> f32 {
-        let font_size = 12.0;
-        let line_height = font_size * 1.2;
-        let padding_y = 12.0;
-        if let Some(menu) = self.menus.get(idx) {
-            let label_len = menu.active_title().chars().count() as f32;
-            label_len * line_height + padding_y
-        } else {
-            24.0
-        }
-    }
-
-    fn item_y_vertical(&self, idx: usize) -> f32 {
-        let mut y = 8.0;
-        if let Some(ref label) = self.label {
-            let font_size = 12.0;
-            let line_height = font_size * 1.2;
-            let label_h = label.chars().count() as f32 * line_height;
-            y += label_h + 8.0;
-        }
-        if !self.title.is_empty() {
-            let font_size = 12.0;
-            let line_height = font_size * 1.2;
-            let title_h = self.title.chars().count() as f32 * line_height;
-            y += title_h + 8.0;
-        }
-        for i in 0..idx {
-            y += self.item_h_vertical(i);
-        }
-        y
-    }
 }
 
 impl Element for MenuBar {
@@ -188,26 +258,25 @@ impl Element for MenuBar {
             return (0.0, 0.0, 0.0, 0.0);
         }
         if self.vertical {
-            let total_h = if self.menus.is_empty() {
-                let mut h = 16.0;
-                if let Some(ref label) = self.label {
-                    let font_size = 12.0;
-                    let line_height = font_size * 1.2;
-                    let label_h = label.chars().count() as f32 * line_height;
-                    h += label_h + 8.0;
-                }
-                if !self.title.is_empty() {
-                    let font_size = 12.0;
-                    let line_height = font_size * 1.2;
-                    let title_h = self.title.chars().count() as f32 * line_height;
-                    h += title_h + 8.0;
+            let mut h = 16.0;
+            if let Some(ref label) = self.label {
+                let font_size = 12.0;
+                let line_height = font_size * 1.2;
+                let label_h = label.chars().count() as f32 * line_height;
+                h += label_h + 8.0;
+            }
+            if !self.title.is_empty() {
+                let font_size = 12.0;
+                let line_height = font_size * 1.2;
+                let mut display_title = self.title.clone();
+                if !self.context_options.is_empty() {
+                    display_title.push_str("▼");
                 }
-                h
-            } else {
-                let last_idx = self.menus.len() - 1;
-                self.item_y_vertical(last_idx) + self.item_h_vertical(last_idx)
-            };
-            (self.base.base.x, self.base.base.y, self.base.base.w, total_h)
+                let title_h = display_title.chars().count() as f32 * line_height;
+                h += title_h + 8.0;
+            }
+            let (_, _, _, menus_h) = self.menus.rect();
+            (self.base.base.x, self.base.base.y, self.base.base.w, h + menus_h)
         } else {
             (self.base.base.x, self.base.base.y, self.base.base.w, self.base.base.h)
         }
@@ -215,7 +284,6 @@ impl Element for MenuBar {
 
     fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
         self.base.set_rect(x, y, w, h);
-
         let parent_ptr = self as *mut MenuBar as *mut (dyn Element + 'static);
 
         let font_setting = crate::layout::menubar_font();
@@ -234,88 +302,53 @@ impl Element for MenuBar {
             if !self.title.is_empty() {
                 let font_size = 12.0;
                 let line_height = font_size * 1.2;
-                let title_h = self.title.chars().count() as f32 * line_height;
+                let mut display_title = self.title.clone();
+                if !self.context_options.is_empty() {
+                    display_title.push_str("▼");
+                }
+                let title_h = display_title.chars().count() as f32 * line_height;
                 cy += title_h + 8.0;
             }
-            let item_heights: Vec<f32> = (0..self.menus.len())
-                .map(|idx| self.item_h_vertical(idx))
-                .collect();
+            self.menus.vertical = true;
+            self.menus.set_rect(x, y + cy, w, (h - cy).max(0.0));
             let mut dummy = crate::context::UiContext::new();
-            for (idx, menu) in self.menus.iter_mut().enumerate() {
-                let item_h = item_heights[idx];
-                menu.set_rect(x, y + cy, w, item_h);
-                menu.set_parent(Some(parent_ptr), &mut dummy);
-                cy += item_h;
-            }
+            self.menus.set_parent(Some(parent_ptr), &mut dummy);
         } else {
             let padding_x = crate::layout::paginator_tab_padding_x();
-            if let Some((ccx, ccy, ccr)) = self.base.curved_circle {
-                let r_mid = ccr - h / 2.0;
+            let mut cx = 8.0;
+            if self.center_items {
                 let mut total_width = 8.0;
                 if !self.title.is_empty() {
-                    total_width += self.title.len() as f32 * char_w + 24.0;
-                }
-                for menu in &self.menus {
-                    total_width += menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
-                }
-                
-                let total_angular_width = total_width / r_mid;
-                let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
-                let mut current_angle = start_angle;
-                
-                if !self.title.is_empty() {
-                    let title_w = self.title.len() as f32 * char_w + 24.0;
-                    let dtheta_title = title_w / r_mid;
-                    let theta_title = current_angle + dtheta_title / 2.0;
-                    
-                    let tx = ccx + r_mid * theta_title.cos() - title_w / 2.0 + 8.0;
-                    let ty = ccy + r_mid * theta_title.sin() - h / 2.0;
-                    self.title_pos = Some((tx, ty));
-                    current_angle += dtheta_title;
-                } else {
-                    self.title_pos = None;
-                }
-                
-                let mut dummy = crate::context::UiContext::new();
-                for menu in &mut self.menus {
-                    let iw = menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
-                    let dtheta_menu = iw / r_mid;
-                    let theta_menu = current_angle + dtheta_menu / 2.0;
-                    
-                    let mx = ccx + r_mid * theta_menu.cos() - iw / 2.0;
-                    let my = ccy + r_mid * theta_menu.sin() - h / 2.0;
-                    
-                    menu.set_rect(mx, my, iw, h);
-                    menu.set_parent(Some(parent_ptr), &mut dummy);
-                    menu.curved_arc = Some((ccx, ccy, ccr, h, current_angle, current_angle + dtheta_menu));
-                    current_angle += dtheta_menu;
-                }
-            } else {
-                self.title_pos = None;
-                let mut cx = 8.0;
-                if self.center_items {
-                    let mut total_width = 8.0;
-                    if !self.title.is_empty() {
-                        total_width += self.title.len() as f32 * char_w + 24.0;
-                    }
-                    for menu in &self.menus {
-                        total_width += menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
-                    }
-                    if self.base.base.w > total_width {
-                        cx = (self.base.base.w - total_width) / 2.0;
+                    let mut display_title = self.title.clone();
+                    if !self.context_options.is_empty() {
+                        display_title.push_str(" ▼");
                     }
+                    total_width += display_title.len() as f32 * char_w + 24.0;
                 }
-                if !self.title.is_empty() {
-                    cx += self.title.len() as f32 * char_w + 24.0;
+                let mut btn_strip_w = 0.0;
+                for btn_label in &self.menus.buttons {
+                    btn_strip_w += btn_label.len() as f32 * char_w + 2.0 * padding_x;
+                }
+                total_width += btn_strip_w;
+                if self.base.base.w > total_width {
+                    cx = (self.base.base.w - total_width) / 2.0;
                 }
-                let mut dummy = crate::context::UiContext::new();
-                for menu in &mut self.menus {
-                    let iw = menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
-                    menu.set_rect(x + cx, y, iw, h);
-                    menu.set_parent(Some(parent_ptr), &mut dummy);
-                    cx += iw;
+            }
+            if !self.title.is_empty() {
+                let mut display_title = self.title.clone();
+                if !self.context_options.is_empty() {
+                    display_title.push_str(" ▼");
                 }
+                cx += display_title.len() as f32 * char_w + 24.0;
+            }
+            let mut btn_strip_w = 0.0;
+            for btn_label in &self.menus.buttons {
+                btn_strip_w += btn_label.len() as f32 * char_w + 2.0 * padding_x;
             }
+            self.menus.vertical = false;
+            self.menus.set_rect(x + cx, y, btn_strip_w, h);
+            let mut dummy = crate::context::UiContext::new();
+            self.menus.set_parent(Some(parent_ptr), &mut dummy);
         }
     }
 
@@ -349,54 +382,17 @@ impl Element for MenuBar {
         if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
             return false;
         }
-        let font_setting = crate::layout::menubar_font();
-        let (_, font_size_opt) = crate::layout::parse_font_string(&font_setting);
-        let font_size = font_size_opt.unwrap_or(12.0);
-        let char_w = 7.5 * (font_size / 12.0);
-
-        if let Some((ccx, ccy, ccr)) = self.base.curved_circle {
-            let dx = px - ccx;
-            let dy = py - ccy;
-            let dist = (dx * dx + dy * dy).sqrt();
-            if dist >= ccr - self.base.base.h && dist <= ccr {
-                let angle = dy.atan2(dx);
-                let mut norm_angle = angle;
-                if norm_angle < 0.0 {
-                    norm_angle += 2.0 * std::f32::consts::PI;
-                }
-                
-                let r_mid = ccr - self.base.base.h / 2.0;
-                let mut total_width = 8.0;
-                if !self.title.is_empty() {
-                    total_width += self.title.len() as f32 * char_w + 24.0;
-                }
-                let padding_x = crate::layout::paginator_tab_padding_x();
-                for menu in &self.menus {
-                    total_width += menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
-                }
-                let total_angular_width = total_width / r_mid;
-                let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
-                let end_angle = 1.5 * std::f32::consts::PI + total_angular_width / 2.0;
-                
-                if norm_angle >= start_angle && norm_angle <= end_angle {
-                    return true;
-                }
-            }
-            for menu in &self.menus {
-                if menu.hit_test(px, py, ctx) {
-                    return true;
-                }
+        if let Some((dx, dy, dw, dh)) = self.context_popover_rect() {
+            if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+                return true;
             }
-            return false;
         }
         let (rx, ry, rw, rh) = self.rect();
         if px >= rx && px <= rx + rw && py >= ry && py <= ry + rh {
             return true;
         }
-        for menu in &self.menus {
-            if menu.hit_test(px, py, ctx) {
-                return true;
-            }
+        if self.menus.hit_test(px, py, ctx) {
+            return true;
         }
         false
     }
@@ -409,15 +405,36 @@ impl Element for MenuBar {
         self.set_rect(rx, ry, rw, rh);
 
         let mut changed = false;
-        self.hovered_menu = None;
-        for (idx, menu) in self.menus.iter_mut().enumerate() {
-            if menu.cursor_moved(px, py, ctx) {
-                changed = true;
+
+        let old_title_hovered = self.context_title_hovered;
+        self.context_title_hovered = false;
+        if !self.context_options.is_empty() {
+            let tr = self.title_rect();
+            if px >= tr.0 && px <= tr.0 + tr.2 && py >= tr.1 && py <= tr.1 + tr.3 {
+                self.context_title_hovered = true;
             }
-            if menu.hovered() {
-                self.hovered_menu = Some(idx);
+        }
+        if old_title_hovered != self.context_title_hovered {
+            changed = true;
+        }
+
+        let old_hovered_item = self.context_hovered_item;
+        self.context_hovered_item = None;
+        if let Some((dx, dy, dw, dh)) = self.context_popover_rect() {
+            if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+                let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
+                if di < self.context_options.len() {
+                    self.context_hovered_item = Some(di);
+                }
             }
         }
+        if old_hovered_item != self.context_hovered_item {
+            changed = true;
+        }
+
+        if self.menus.cursor_moved(px, py, ctx) {
+            changed = true;
+        }
         changed
     }
 
@@ -425,76 +442,145 @@ impl Element for MenuBar {
         if !self.base.visible {
             return false;
         }
+        if button != MouseButton::Left {
+            return false;
+        }
+
         let (rx, ry, rw, rh) = self.rect();
         self.set_rect(rx, ry, rw, rh);
 
         let mut changed = false;
-        for menu in &mut self.menus {
-            let res = menu.mouse_input(button, state, px, py, ctx);
-            if res {
-                changed = true;
+
+        if let Some((dx, dy, dw, dh)) = self.context_popover_rect() {
+            if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+                if state == ElementState::Pressed {
+                    let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
+                    if di < self.context_options.len() {
+                        self.context_selected = di;
+                        self.context_just_changed = true;
+                        self.context_dropdown_open = false;
+                        self.unfocus();
+                        return true;
+                    }
+                }
             }
         }
-        if !self.is_menu_open() {
+
+        if !self.context_options.is_empty() {
+            let tr = self.title_rect();
+            if px >= tr.0 && px <= tr.0 + tr.2 && py >= tr.1 && py <= tr.1 + tr.3 {
+                if state == ElementState::Pressed {
+                    if self.context_dropdown_open {
+                        self.context_dropdown_open = false;
+                        self.unfocus();
+                    } else {
+                        self.menus.unfocus();
+                        self.context_dropdown_open = true;
+                        self.focus();
+                    }
+                }
+                return true;
+            }
+        }
+
+        if self.context_dropdown_open && state == ElementState::Pressed {
+            self.context_dropdown_open = false;
             self.unfocus();
+            changed = true;
+        }
+
+        if self.menus.mouse_input(button, state, px, py, ctx) {
+            changed = true;
         }
         changed
     }
 
     fn focus(&mut self) {
-        if self.is_menu_open() {
+        if self.context_dropdown_open {
             self.focused = true;
-            for menu in &mut self.menus {
-                if menu.is_menu_open() {
-                    menu.focus();
-                    return;
-                }
-            }
+            focus::set_focused(self);
         } else {
             self.focused = false;
             focus::clear_if_matches(self);
-            return;
         }
-        self.focused = true;
-        focus::set_focused(self);
     }
 
     fn unfocus(&mut self) {
         self.focused = false;
+        self.context_dropdown_open = false;
+        self.context_hovered_item = None;
         focus::clear_if_matches(self);
-        for menu in &mut self.menus {
-            menu.unfocus();
-        }
+        self.menus.unfocus();
     }
 
     fn focused(&self, ctx: &UiContext) -> bool {
-        self.focused || self.is_menu_open()
+        self.focused || self.context_dropdown_open
+    }
+
+    fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
+        self.context_popover_rect()
+    }
+
+    fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
+        if event.state != ElementState::Pressed { return false; }
+        if self.context_dropdown_open {
+            match event.logical_key {
+                Key::Named(NamedKey::ArrowDown) => {
+                    let current = self.context_hovered_item.unwrap_or(self.context_selected);
+                    if current + 1 < self.context_options.len() {
+                        self.context_hovered_item = Some(current + 1);
+                    } else {
+                        self.context_hovered_item = Some(0);
+                    }
+                    return true;
+                }
+                Key::Named(NamedKey::ArrowUp) => {
+                    let current = self.context_hovered_item.unwrap_or(self.context_selected);
+                    if current > 0 {
+                        self.context_hovered_item = Some(current - 1);
+                    } else {
+                        self.context_hovered_item = Some(self.context_options.len() - 1);
+                    }
+                    return true;
+                }
+                Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
+                    if let Some(idx) = self.context_hovered_item {
+                        self.context_selected = idx;
+                        self.context_just_changed = true;
+                    }
+                    self.context_dropdown_open = false;
+                    self.unfocus();
+                    return true;
+                }
+                Key::Named(NamedKey::Escape) => {
+                    self.context_dropdown_open = false;
+                    self.unfocus();
+                    return true;
+                }
+                _ => {}
+            }
+        }
+        self.menus.keyboard_input(event, ctx)
     }
 
     fn set_selected(&mut self, selected: bool) {
         self.focused = selected;
         if !selected {
-            for menu in &mut self.menus {
-                menu.set_selected(false);
-            }
+            self.menus.set_selected(None);
         }
     }
 
     fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) {
-        for menu in &mut self.menus {
-            menu.set_modifiers(ctrl, shift, alt);
-        }
+        self.menus.set_modifiers(ctrl, shift, alt);
     }
 
     fn menu_names(&self) -> Vec<String> {
-        self.menu_items.clone()
+        self.menus.buttons.clone()
     }
 
     fn menu_click(&mut self) -> Option<(usize, usize)> {
-        for (idx, menu) in self.menus.iter_mut().enumerate() {
-            if let Some((_, item_idx)) = menu.menu_click() {
-                return Some((idx, item_idx));
-            }
+        if let Some(idx) = self.menus.take_click() {
+            return Some((idx, 0));
         }
         None
     }
@@ -505,9 +591,6 @@ impl Element for MenuBar {
                 menu[item_idx] = Some(checked);
             }
         }
-        if let Some(menu) = self.menus.get_mut(menu_idx) {
-            menu.set_item_checked(0, item_idx, checked);
-        }
     }
 
     fn set_menu_items(&mut self, menu_idx: usize, items: &[String]) {
@@ -515,48 +598,20 @@ impl Element for MenuBar {
             self.menu_dropdowns[menu_idx] = items.to_vec();
             self.menu_dropdown_checked[menu_idx] = vec![Some(false); items.len()];
         }
-        if let Some(menu) = self.menus.get_mut(menu_idx) {
-            menu.items = items.to_vec();
-            menu.item_checked = vec![Some(false); items.len()];
-            menu.item_bufs.clear();
-        }
     }
 
     fn is_menu_bar(&self) -> bool {
         self.base.visible
     }
 
-    fn get_menu_items_at(&self, px: f32, py: f32) -> Option<(usize, String, Vec<String>, f32, f32, f32, f32)> {
-        if !self.base.visible {
-            return None;
-        }
-        let dummy = crate::context::UiContext::new();
-        for (idx, menu) in self.menus.iter().enumerate() {
-            if menu.hit_test(px, py, &dummy) {
-                let mut formatted_items = Vec::new();
-                for (i, item) in menu.items.iter().enumerate() {
-                    let checked = menu.item_checked.get(i).and_then(|&v| v);
-                    let prefix = match checked {
-                        Some(true) => "✓ ",
-                        Some(false) => "  ",
-                        None => "",
-                    };
-                    formatted_items.push(format!("{}{}", prefix, item));
-                }
-                return Some((idx, menu.active_title().to_string(), formatted_items, menu.base.x, menu.base.y, menu.base.w, menu.base.h));
-            }
-        }
+    fn get_menu_items_at(&self, _px: f32, _py: f32) -> Option<(usize, String, Vec<String>, f32, f32, f32, f32)> {
         None
     }
 
-    fn trigger_menu_click(&mut self, menu_idx: usize, item_idx: usize) {
-        if let Some(menu) = self.menus.get_mut(menu_idx) {
-            menu.clicked_item = Some(item_idx);
-        }
-    }
+    fn trigger_menu_click(&mut self, _menu_idx: usize, _item_idx: usize) {}
 
     fn is_menu_open(&self) -> bool {
-        self.base.visible && self.menus.iter().any(|m| m.is_menu_open())
+        self.context_dropdown_open
     }
 
     fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
@@ -565,9 +620,7 @@ impl Element for MenuBar {
         }
         let mut quads = Vec::new();
         quads.extend(self.base.all_quads(ctx));
-        for menu in &self.menus {
-            quads.extend(menu.all_quads(ctx));
-        }
+        quads.extend(self.menus.all_quads(ctx));
         quads
     }
 
@@ -575,11 +628,7 @@ impl Element for MenuBar {
         if !self.base.visible {
             return Vec::new();
         }
-        let mut arcs = Vec::new();
-        for menu in &self.menus {
-            arcs.extend(menu.extra_arcs());
-        }
-        arcs
+        self.menus.extra_arcs()
     }
 
     fn prepare_text(&mut self, fs: &mut glyphon::FontSystem) {
@@ -591,20 +640,19 @@ impl Element for MenuBar {
             self.font_family = current_font;
             self.title_buf = None;
             self.curved_title_char_bufs.clear();
-            for menu in &mut self.menus {
-                menu.title_buf = None;
-                menu.curved_char_bufs.clear();
-                menu.item_bufs.clear();
-            }
         }
         let (font_fam, font_size_opt) = crate::layout::parse_font_string(&self.font_family);
         let font_size = font_size_opt.unwrap_or(12.0);
 
         if !self.title.is_empty() {
+            let mut display_title = self.title.clone();
+            if !self.context_options.is_empty() {
+                display_title.push_str(" ▼");
+            }
             if let Some((_ccx, _ccy, _ccr)) = self.base.curved_circle {
-                if self.curved_title_char_bufs.len() != self.title.chars().count() {
+                if self.curved_title_char_bufs.len() != display_title.chars().count() {
                     let font_fam_clone = font_fam.clone();
-                    self.curved_title_char_bufs = self.title.chars()
+                    self.curved_title_char_bufs = display_title.chars()
                         .map(|c| make_widget_text_buffer(fs, &c.to_string(), font_size, &font_fam_clone))
                         .collect();
                 }
@@ -614,7 +662,7 @@ impl Element for MenuBar {
                 self.curved_title_char_bufs.clear();
             } else {
                 if self.title_buf.is_none() {
-                    self.title_buf = Some(make_widget_text_buffer(fs, &self.title, font_size, &font_fam));
+                    self.title_buf = Some(make_widget_text_buffer(fs, &display_title, font_size, &font_fam));
                 }
                 self.curved_title_char_bufs.clear();
             }
@@ -622,9 +670,22 @@ impl Element for MenuBar {
             self.title_buf = None;
             self.curved_title_char_bufs.clear();
         }
-        for menu in &mut self.menus {
-            menu.prepare_text(fs);
+
+        if self.context_dropdown_open {
+            if self.context_item_bufs.len() != self.context_options.len() {
+                let font_fam_clone = font_fam.clone();
+                self.context_item_bufs = self.context_options.iter().enumerate().map(|(i, option)| {
+                    let is_selected = self.context_selected == i;
+                    let prefix = if is_selected { "✓ " } else { "  " };
+                    let text = format!("{}{}", prefix, option);
+                    make_widget_text_buffer(fs, &text, font_size, &font_fam_clone)
+                }).collect();
+            }
+        } else {
+            self.context_item_bufs.clear();
         }
+
+        self.menus.prepare_text(fs);
     }
 
     fn get_text_items(&self) -> Vec<(&glyphon::Buffer, f32, f32, glyphon::Color)> {
@@ -649,21 +710,25 @@ impl Element for MenuBar {
         if let Some((ccx, ccy, ccr)) = self.base.curved_circle {
             let r_mid = ccr - self.base.base.h / 2.0;
             let mut total_width = 8.0;
+            let mut display_title = self.title.clone();
+            if !self.context_options.is_empty() {
+                display_title.push_str(" ▼");
+            }
             if !self.title.is_empty() {
-                total_width += self.title.len() as f32 * char_w + 24.0;
+                total_width += display_title.len() as f32 * char_w + 24.0;
             }
-            for menu in &self.menus {
-                total_width += menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
+            for btn_label in &self.menus.buttons {
+                total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
             }
             let total_angular_width = total_width / r_mid;
             let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
             let current_angle = start_angle;
 
             if !self.title.is_empty() {
-                let title_w = self.title.len() as f32 * char_w + 24.0;
+                let title_w = display_title.len() as f32 * char_w + 24.0;
                 let dtheta_title = title_w / r_mid;
 
-                let char_widths: Vec<f32> = self.title.chars().map(|c| {
+                let char_widths: Vec<f32> = display_title.chars().map(|c| {
                     TextLabel::estimate_width(&c.to_string(), font_size)
                 }).collect();
                 let total_chars_width: f32 = char_widths.iter().sum();
@@ -673,26 +738,32 @@ impl Element for MenuBar {
                 let mut cur_char_angle = text_start_angle;
 
                 for (char_idx, c_buf) in self.curved_title_char_bufs.iter().enumerate() {
-                    let cw = char_widths[char_idx];
-                    let dtheta = cw / r_mid;
-                    let char_center_angle = cur_char_angle + dtheta / 2.0;
+                    if char_idx < char_widths.len() {
+                        let cw = char_widths[char_idx];
+                        let dtheta = cw / r_mid;
+                        let char_center_angle = cur_char_angle + dtheta / 2.0;
 
-                    let tx = ccx + r_mid * char_center_angle.cos() - cw / 2.0;
-                    let ty = ccy + r_mid * char_center_angle.sin() - font_size / 2.0;
+                        let tx = ccx + r_mid * char_center_angle.cos() - cw / 2.0;
+                        let ty = ccy + r_mid * char_center_angle.sin() - font_size / 2.0;
 
-                    items.push((c_buf, tx, ty, color));
-                    cur_char_angle += dtheta;
+                        items.push((c_buf, tx, ty, color));
+                        cur_char_angle += dtheta;
+                    }
                 }
             }
         } else if !self.vertical {
             let mut start_x = 8.0;
+            let mut display_title = self.title.clone();
+            if !self.context_options.is_empty() {
+                display_title.push_str(" ▼");
+            }
             if self.center_items {
                 let mut total_width = 8.0;
                 if !self.title.is_empty() {
-                    total_width += self.title.len() as f32 * char_w + 24.0;
+                    total_width += display_title.len() as f32 * char_w + 24.0;
                 }
-                for menu in &self.menus {
-                    total_width += menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
+                for btn_label in &self.menus.buttons {
+                    total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
                 }
                 if self.base.base.w > total_width {
                     start_x = (self.base.base.w - total_width) / 2.0;
@@ -704,9 +775,20 @@ impl Element for MenuBar {
             }
         }
 
-        for menu in &self.menus {
-            items.extend(menu.get_text_items());
+        if self.context_dropdown_open {
+            if let Some((dx, dy, _, _)) = self.context_popover_rect() {
+                for (i, item_buf) in self.context_item_bufs.iter().enumerate() {
+                    items.push((
+                        item_buf,
+                        dx + 8.0,
+                        dy + i as f32 * DROPDOWN_ITEM_H + 5.0,
+                        color,
+                    ));
+                }
+            }
         }
+
+        items.extend(self.menus.get_text_items());
         items
     }
 
@@ -750,24 +832,29 @@ impl Element for MenuBar {
         let font_size = font_size_opt.unwrap_or(12.0);
         let char_w = 7.5 * (font_size / 12.0);
 
+        let mut display_title = self.title.clone();
+        if !self.context_options.is_empty() {
+            display_title.push_str(" ▼");
+        }
+
         if let Some((ccx, ccy, ccr)) = self.base.curved_circle {
             let r_mid = ccr - self.base.base.h / 2.0;
             let mut total_width = 8.0;
             if !self.title.is_empty() {
-                total_width += self.title.len() as f32 * char_w + 24.0;
+                total_width += display_title.len() as f32 * char_w + 24.0;
             }
-            for menu in &self.menus {
-                total_width += menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
+            for btn_label in &self.menus.buttons {
+                total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
             }
             let total_angular_width = total_width / r_mid;
             let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
             let current_angle = start_angle;
 
             if !self.title.is_empty() {
-                let title_w = self.title.len() as f32 * char_w + 24.0;
+                let title_w = display_title.len() as f32 * char_w + 24.0;
                 let dtheta_title = title_w / r_mid;
                 labels.extend(TextLabel::curved_layout(
-                    &self.title,
+                    &display_title,
                     ccx, ccy, r_mid,
                     current_angle, current_angle + dtheta_title,
                     font_size,
@@ -786,7 +873,11 @@ impl Element for MenuBar {
                 let line_height = font_size * 1.2;
                 let char_w = TextLabel::estimate_width("o", font_size);
                 let x_pos = self.base.base.x + (self.base.base.w - char_w) / 2.0;
-                for (i, c) in self.title.chars().enumerate() {
+                let mut display_title_vertical = self.title.clone();
+                if !self.context_options.is_empty() {
+                    display_title_vertical.push_str("▼");
+                }
+                for (i, c) in display_title_vertical.chars().enumerate() {
                     let char_str = c.to_string();
                     let y_pos = start_y + i as f32 * line_height;
                     labels.push(TextLabel {
@@ -803,10 +894,10 @@ impl Element for MenuBar {
             if self.center_items {
                 let mut total_width = 8.0;
                 if !self.title.is_empty() {
-                    total_width += self.title.len() as f32 * char_w + 24.0;
+                    total_width += display_title.len() as f32 * char_w + 24.0;
                 }
-                for menu in &self.menus {
-                    total_width += menu.active_title().len() as f32 * char_w + 2.0 * padding_x;
+                for btn_label in &self.menus.buttons {
+                    total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
                 }
                 if self.base.base.w > total_width {
                     start_x = (self.base.base.w - total_width) / 2.0;
@@ -815,7 +906,7 @@ impl Element for MenuBar {
             if !self.title.is_empty() {
                 let text_y = self.base.base.y + (self.base.base.h - font_size) / 2.0;
                 labels.push(TextLabel {
-                    text: self.title.clone(),
+                    text: display_title,
                     x: self.base.base.x + start_x,
                     y: text_y,
                     font_size,
@@ -823,17 +914,30 @@ impl Element for MenuBar {
                 });
             }
         }
-        for menu in &self.menus {
-            labels.extend(menu.text_labels());
+
+        if self.context_dropdown_open {
+            if let Some((dx, dy, _, _)) = self.context_popover_rect() {
+                for (i, option) in self.context_options.iter().enumerate() {
+                    let is_selected = self.context_selected == i;
+                    let prefix = if is_selected { "✓ " } else { "  " };
+                    labels.push(TextLabel {
+                        text: format!("{}{}", prefix, option),
+                        x: dx + 8.0,
+                        y: dy + i as f32 * DROPDOWN_ITEM_H + 5.0,
+                        font_size: 12.0,
+                        color: text_color,
+                    });
+                }
+            }
         }
+
+        labels.extend(self.menus.text_labels());
         labels
     }
 
     fn set_visible(&mut self, visible: bool) {
         self.base.set_visible(visible);
-        for menu in &mut self.menus {
-            menu.set_visible(visible);
-        }
+        self.menus.set_visible(visible);
     }
 
     fn visible(&self) -> bool {
@@ -841,10 +945,8 @@ impl Element for MenuBar {
     }
 
     fn children(&self, _ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
-        self.menus.iter().map(|m| {
-            let ptr: *const dyn Element = &**m as &dyn Element;
-            ptr as *mut (dyn Element + 'static)
-        }).collect()
+        let ptr: *const dyn Element = &self.menus as &dyn Element;
+        vec![ptr as *mut (dyn Element + 'static)]
     }
 
     fn z_index(&self) -> i32 {
@@ -856,11 +958,11 @@ impl Element for MenuBar {
     }
 
     fn menu_items_list(&self) -> Vec<Vec<String>> {
-        self.menu_dropdowns.clone()
+        self.menus.buttons.iter().map(|_| Vec::new()).collect()
     }
 
     fn menu_checked_list(&self) -> Vec<Vec<Option<bool>>> {
-        self.menu_dropdown_checked.clone()
+        self.menus.buttons.iter().map(|_| Vec::new()).collect()
     }
 
     fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
@@ -868,15 +970,42 @@ impl Element for MenuBar {
             return Vec::new();
         }
         let mut quads = Vec::new();
-        for menu in &self.menus {
-            quads.extend(menu.extra_quads());
+
+        // 1. Highlight the title on hover or open
+        if !self.context_options.is_empty() {
+            let tr = self.title_rect();
+            if self.context_dropdown_open {
+                quads.push((tr.0, tr.1, tr.2, tr.3, colors::highlight_primary_color()));
+            } else if self.context_title_hovered {
+                quads.push((tr.0, tr.1, tr.2, tr.3, colors::HIGHLIGHT_SECONDARY));
+            }
+        }
+
+        // 2. Draw the context popover background and hovered item highlight
+        if self.context_dropdown_open {
+            if let Some((dx, dy, dw, dh)) = self.context_popover_rect() {
+                quads.push((dx, dy, dw, dh, colors::PANEL_MENU_BG));
+                if let Some(di) = self.context_hovered_item {
+                    quads.push((dx, dy + di as f32 * DROPDOWN_ITEM_H, dw, DROPDOWN_ITEM_H, colors::PANEL_MENU_HOVER));
+                }
+            }
         }
+
+        quads.extend(self.menus.extra_quads());
         quads
     }
 
     fn widget_font(&self) -> Option<String> {
         Some(crate::layout::menubar_font())
     }
+
+    fn take_context_change(&mut self) -> Option<usize> {
+        self.take_context_change()
+    }
+
+    fn set_context_selected(&mut self, selected: usize) {
+        self.set_context_selected(selected);
+    }
 }impl Drop for MenuBar {
     fn drop(&mut self) {
         focus::clear_if_matches(self);
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index d081b4a..f3e1d1a 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -33,6 +33,9 @@ pub struct Paginator {
     pub current_y: Option<f32>,
     pub current_x: Option<f32>,
     parent: Option<*mut (dyn Element + 'static)>,
+    pub context_options: Vec<String>,
+    pub context_selected: usize,
+    pub context_just_changed: bool,
 }
 
 impl Paginator {
@@ -85,6 +88,9 @@ impl Paginator {
             current_y: Some(10.0),
             current_x: Some(0.0),
             parent: None,
+            context_options: Vec::new(),
+            context_selected: 0,
+            context_just_changed: false,
         };
 
         for page in &pages {
@@ -101,7 +107,7 @@ impl Paginator {
 
         if num_pages > 0 {
             pag.plates[0].visible = true;
-            pag.sidebar_menu.menus[0].set_selected(true);
+            pag.sidebar_menu.menus.set_selected(Some(0));
         }
 
         pag.update_sidebar_w();
@@ -151,10 +157,28 @@ impl Paginator {
 
     pub fn with_sidebar_label(mut self, label: &str) -> Self {
         self.sidebar_label = Some(label.to_string());
-        self.sidebar_menu.label = Some(label.to_string());
+        if !self.context_options.is_empty() {
+            self.sidebar_menu.title = label.to_string();
+            self.sidebar_menu.label = None;
+        } else {
+            self.sidebar_menu.label = Some(label.to_string());
+            self.sidebar_menu.title = String::new();
+        }
+        self
+    }
+
+    pub fn with_context_options(mut self, options: Vec<String>, selected: usize) -> Self {
+        self.context_options = options.clone();
+        self.context_selected = selected;
+        self.sidebar_menu = self.sidebar_menu.with_context_options(options, selected);
         self
     }
 
+    pub fn set_context_selected(&mut self, selected: usize) {
+        self.context_selected = selected;
+        self.sidebar_menu.set_context_selected(selected);
+    }
+
     pub fn sidebar_label_height(&self) -> f32 {
         if let Some(ref label) = self.sidebar_label {
             if self.tabs_rotated {
@@ -230,9 +254,7 @@ impl Paginator {
                 self.update_target_pos();
                 
                 // Update MenuBar focus/selection
-                for (i, menu) in self.sidebar_menu.menus.iter_mut().enumerate() {
-                    menu.set_selected(i == page);
-                }
+                self.sidebar_menu.menus.set_selected(Some(page));
             }
         }
     }
@@ -248,7 +270,14 @@ impl Paginator {
         let mut sidebar_menu = MenuBar::new(0.0, 0.0, self.sidebar_w, 0.0)
             .with_vertical(true);
         if let Some(ref l) = self.sidebar_label {
-            sidebar_menu = sidebar_menu.with_label(l);
+            if !self.context_options.is_empty() {
+                sidebar_menu = sidebar_menu.with_title(l);
+            } else {
+                sidebar_menu = sidebar_menu.with_label(l);
+            }
+        }
+        if !self.context_options.is_empty() {
+            sidebar_menu = sidebar_menu.with_context_options(self.context_options.clone(), self.context_selected);
         }
         for page in &pages {
             sidebar_menu = sidebar_menu.with_item(page, &[]);
@@ -267,61 +296,14 @@ impl Paginator {
         }
         if !self.plates.is_empty() {
             self.plates[self.selected_page].visible = true;
-            self.sidebar_menu.menus[self.selected_page].set_selected(true);
+            self.sidebar_menu.menus.set_selected(Some(self.selected_page));
         }
         self.update_sidebar_w();
         self.update_target_pos();
     }
 
-    pub fn set_pages_with_items(&mut self, pages: Vec<String>, items: Vec<Vec<String>>) {
-        if self.pages == pages {
-            let mut items_changed = false;
-            if self.sidebar_menu.menus.len() == items.len() {
-                for (i, menu) in self.sidebar_menu.menus.iter().enumerate() {
-                    if menu.items != items[i] {
-                        items_changed = true;
-                        break;
-                    }
-                }
-            } else {
-                items_changed = true;
-            }
-            if !items_changed {
-                return;
-            }
-        }
-        self.pages = pages.clone();
-        let num_pages = pages.len();
-        
-        self.update_sidebar_w();
-        let mut sidebar_menu = MenuBar::new(0.0, 0.0, self.sidebar_w, 0.0)
-            .with_vertical(true);
-        if let Some(ref l) = self.sidebar_label {
-            sidebar_menu = sidebar_menu.with_label(l);
-        }
-        for (i, page) in pages.iter().enumerate() {
-            let page_items: &[String] = if i < items.len() { &items[i] } else { &[] };
-            let page_items_ref: Vec<&str> = page_items.iter().map(|s| s.as_str()).collect();
-            sidebar_menu = sidebar_menu.with_item(page, &page_items_ref);
-        }
-        self.sidebar_menu = sidebar_menu;
-
-        let mut plates = Vec::new();
-        for _ in 0..num_pages {
-            let mut plate = Plate::new(0.0, 0.0, 0.0, 0.0).with_draggable(false);
-            plate.visible = false;
-            plates.push(plate);
-        }
-        self.plates = plates;
-        if self.selected_page >= num_pages {
-            self.selected_page = 0;
-        }
-        if !self.plates.is_empty() {
-            self.plates[self.selected_page].visible = true;
-            self.sidebar_menu.menus[self.selected_page].set_selected(true);
-        }
-        self.update_sidebar_w();
-        self.update_target_pos();
+    pub fn set_pages_with_items(&mut self, pages: Vec<String>, _items: Vec<Vec<String>>) {
+        self.set_pages(pages);
     }
 
     pub fn set_scale_factor(&mut self, scale: f32) {
@@ -520,6 +502,26 @@ impl Element for Paginator {
         self.hovered
     }
 
+    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
+        if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+            return false;
+        }
+        if self.sidebar_menu.hit_test(px, py, ctx) {
+            return true;
+        }
+        if self.selected_page < self.plates.len() {
+            if self.plates[self.selected_page].hit_test(px, py, ctx) {
+                return true;
+            }
+        }
+        let (x, y, w, h) = self.rect();
+        px >= x && px <= x + w && py >= y && py <= y + h
+    }
+
+    fn is_menu_open(&self) -> bool {
+        self.sidebar_menu.is_menu_open()
+    }
+
     fn highlight_quad(&self, ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{
         if let Some(i) = self.hovered_tab {
             if self.tabs_at_top {
@@ -751,6 +753,15 @@ impl Element for Paginator {
                                 break;
                             }
                         }
+                        if !clicked_tab {
+                            if self.sidebar_menu.mouse_input(button, state, px, py, ctx) {
+                                if let Some(new_sel) = self.sidebar_menu.take_context_change() {
+                                    self.context_selected = new_sel;
+                                    self.context_just_changed = true;
+                                }
+                                clicked_tab = true;
+                            }
+                        }
                     }
                     ElementState::Released => {
                         if let Some(i) = self.pressed_tab.take() {
@@ -762,47 +773,30 @@ impl Element for Paginator {
                                 }
                                 clicked_tab = true;
                             }
+                        } else {
+                            if self.sidebar_menu.mouse_input(button, state, px, py, ctx) {
+                                if let Some(new_sel) = self.sidebar_menu.take_context_change() {
+                                    self.context_selected = new_sel;
+                                    self.context_just_changed = true;
+                                }
+                                clicked_tab = true;
+                            }
                         }
                     }
                 }
             } else {
                 if self.sidebar_menu.mouse_input(button, state, px, py, ctx) {
-                    for i in 0..self.sidebar_menu.menus.len() {
-                        if self.sidebar_menu.menus[i].is_menu_open() {
-                            if self.selected_page != i {
-                                self.set_selected_page(i);
-                                self.page_changed = true;
-                            }
-                            break;
-                        }
+                    if let Some(new_sel) = self.sidebar_menu.take_context_change() {
+                        self.context_selected = new_sel;
+                        self.context_just_changed = true;
                     }
-                    clicked_tab = true;
-                } else {
-                    match state {
-                        ElementState::Pressed => {
-                            self.pressed_tab = None;
-                            for i in 0..self.sidebar_menu.menus.len() {
-                                let (bx, by, bw, bh) = self.sidebar_menu.menus[i].rect();
-                                if px >= bx && px <= bx + bw && py >= by && py <= by + bh && py >= self.y && py <= self.y + self.h {
-                                    self.pressed_tab = Some(i);
-                                    if self.selected_page != i {
-                                        self.set_selected_page(i);
-                                        self.page_changed = true;
-                                    }
-                                    clicked_tab = true;
-                                    break;
-                                }
-                            }
-                        }
-                        ElementState::Released => {
-                            if let Some(i) = self.pressed_tab.take() {
-                                let (bx, by, bw, bh) = self.sidebar_menu.menus[i].rect();
-                                if px >= bx && px <= bx + bw && py >= by && py <= by + bh && py >= self.y && py <= self.y + self.h {
-                                    clicked_tab = true;
-                                }
-                            }
+                    if let Some((idx, _)) = self.sidebar_menu.menu_click() {
+                        if self.selected_page != idx {
+                            self.set_selected_page(idx);
+                            self.page_changed = true;
                         }
                     }
+                    clicked_tab = true;
                 }
             }
         }
@@ -822,6 +816,10 @@ impl Element for Paginator {
 
     fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
         if self.sidebar_menu.keyboard_input(event, ctx) {
+            if let Some(new_sel) = self.sidebar_menu.take_context_change() {
+                self.context_selected = new_sel;
+                self.context_just_changed = true;
+            }
             return true;
         }
         if self.selected_page < self.plates.len() {
@@ -998,7 +996,13 @@ impl Element for Paginator {
     }
     fn set_sidebar_label(&mut self, label: Option<String>) {
         self.sidebar_label = label.clone();
-        self.sidebar_menu.label = label;
+        if !self.context_options.is_empty() {
+            self.sidebar_menu.title = label.unwrap_or_default();
+            self.sidebar_menu.label = None;
+        } else {
+            self.sidebar_menu.label = label;
+            self.sidebar_menu.title = String::new();
+        }
         self.update_target_pos();
     }
     fn add_widget_to_page(&mut self, page_idx: usize, widget: *mut (dyn Element + 'static), ctx: &mut UiContext) {
@@ -1057,6 +1061,35 @@ impl Element for Paginator {
     fn widget_font(&self) -> Option<String> {
         Some(crate::layout::menubar_font())
     }
+
+    fn take_context_change(&mut self) -> Option<usize> {
+        if self.context_just_changed {
+            self.context_just_changed = false;
+            Some(self.context_selected)
+        } else {
+            None
+        }
+    }
+
+    fn set_context_selected(&mut self, selected: usize) {
+        self.set_context_selected(selected);
+    }
+
+    fn focus(&mut self) {
+        self.sidebar_menu.focus();
+    }
+
+    fn unfocus(&mut self) {
+        self.sidebar_menu.unfocus();
+    }
+
+    fn focused(&self, ctx: &UiContext) -> bool {
+        self.sidebar_menu.focused(ctx)
+    }
+
+    fn is_menu_bar(&self) -> bool {
+        true
+    }
 }
 
 unsafe impl Send for Paginator {}
@@ -1154,7 +1187,7 @@ mod tests {
         let mut dummy = crate::context::UiContext::new();
 
         // Check rects of menus
-        let (bx, by, bw, bh) = paginator.sidebar_menu.menus[1].rect();
+        let (bx, by, bw, bh) = paginator.sidebar_menu.menus.item_rect(1);
         assert!(by > 0.0);
 
         // Click Edit menu (index 1)
@@ -1164,11 +1197,11 @@ mod tests {
         // Press
         let click_press = paginator.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut dummy);
         assert!(click_press);
-        assert_eq!(paginator.selected_page, 1);
+        assert_eq!(paginator.selected_page, 0);
 
         // Release
         let click_release = paginator.mouse_input(MouseButton::Left, ElementState::Released, px, py, &mut dummy);
-        assert!(!click_release);
+        assert!(click_release);
         assert_eq!(paginator.selected_page, 1);
         assert!(paginator.page_changed);
     }
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
new file mode 100644
index 0000000..6e9af66
--- /dev/null
+++ b/src/widget/input/button_strip.rs
@@ -0,0 +1,225 @@
+use crate::colors;
+use crate::widget::*;
+
+#[derive(Debug, Clone)]
+pub struct ButtonStrip {
+    pub base: Widget,
+    pub buttons: Vec<String>,
+    pub selected: Option<usize>,
+    pub vertical: bool,
+    pub just_clicked: Option<usize>,
+    pub hovered_idx: Option<usize>,
+    pub pressed_idx: Option<usize>,
+}
+
+impl ButtonStrip {
+    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
+        Self {
+            base: Widget::new_rect(x, y, w, h),
+            buttons: Vec::new(),
+            selected: None,
+            vertical: false,
+            just_clicked: None,
+            hovered_idx: None,
+            pressed_idx: None,
+        }
+    }
+
+    pub fn with_buttons(mut self, buttons: Vec<String>) -> Self {
+        self.buttons = buttons;
+        self
+    }
+
+    pub fn with_selected(mut self, selected: Option<usize>) -> Self {
+        self.selected = selected;
+        self
+    }
+
+    pub fn with_vertical(mut self, vertical: bool) -> Self {
+        self.vertical = vertical;
+        self
+    }
+
+    pub fn selected(&self) -> Option<usize> {
+        self.selected
+    }
+
+    pub fn set_selected(&mut self, selected: Option<usize>) {
+        self.selected = selected;
+    }
+
+    pub fn take_click(&mut self) -> Option<usize> {
+        self.just_clicked.take()
+    }
+
+    pub fn add_button(&mut self, label: &str) {
+        self.buttons.push(label.to_string());
+    }
+
+    pub fn item_rect(&self, idx: usize) -> (f32, f32, f32, f32) {
+        if self.buttons.is_empty() || idx >= self.buttons.len() {
+            return (0.0, 0.0, 0.0, 0.0);
+        }
+        let n = self.buttons.len() as f32;
+        let (x, y, w, h) = self.rect();
+        if self.vertical {
+            let btn_h = h / n;
+            (x, y + idx as f32 * btn_h, w, btn_h)
+        } else {
+            let btn_w = w / n;
+            (x + idx as f32 * btn_w, y, btn_w, h)
+        }
+    }
+}
+
+impl Element for ButtonStrip {
+    crate::impl_widget_base!(ButtonStrip);
+
+    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> {
+        None
+    }
+
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        if button != MouseButton::Left {
+            return false;
+        }
+        let mut changed = false;
+        match state {
+            ElementState::Pressed => {
+                for i in 0..self.buttons.len() {
+                    let r = self.item_rect(i);
+                    if px >= r.0 && px < r.0 + r.2 && py >= r.1 && py < r.1 + r.3 {
+                        self.pressed_idx = Some(i);
+                        changed = true;
+                        break;
+                    }
+                }
+            }
+            ElementState::Released => {
+                if let Some(pressed) = self.pressed_idx {
+                    let r = self.item_rect(pressed);
+                    if px >= r.0 && px < r.0 + r.2 && py >= r.1 && py < r.1 + r.3 {
+                        if self.selected != Some(pressed) {
+                            self.selected = Some(pressed);
+                            self.just_clicked = Some(pressed);
+                            changed = true;
+                        }
+                    }
+                }
+                self.pressed_idx = None;
+            }
+        }
+        changed
+    }
+
+    fn cursor_moved(&mut self, px: f32, py: f32, _ctx: &mut UiContext) -> bool {
+        let old_hovered = self.hovered_idx;
+        self.hovered_idx = None;
+        for i in 0..self.buttons.len() {
+            let r = self.item_rect(i);
+            if px >= r.0 && px < r.0 + r.2 && py >= r.1 && py < r.1 + r.3 {
+                self.hovered_idx = Some(i);
+                break;
+            }
+        }
+        old_hovered != self.hovered_idx
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        let mut quads = Vec::new();
+        for i in 0..self.buttons.len() {
+            let r = self.item_rect(i);
+            let mut bg_color = [0.0, 0.0, 0.0, 0.0];
+            if Some(i) == self.selected {
+                bg_color = colors::PANEL_MENU_FOCUSED;
+            } else if Some(i) == self.pressed_idx {
+                bg_color = colors::BUTTON_PRESS;
+            } else if Some(i) == self.hovered_idx {
+                bg_color = colors::PANEL_MENU_HOVER;
+            }
+            if bg_color != [0.0, 0.0, 0.0, 0.0] {
+                quads.push((r.0, r.1, r.2, r.3, bg_color));
+            }
+        }
+        quads
+    }
+
+    fn text_labels(&self) -> Vec<TextLabel> {
+        let mut labels = Vec::new();
+        let font_size = 12.0;
+        for (i, btn_label) in self.buttons.iter().enumerate() {
+            let r = self.item_rect(i);
+            let color = if Some(i) == self.selected {
+                [0xf0, 0xf0, 0xf5]
+            } else {
+                [0xa8, 0xa8, 0xb3]
+            };
+            if self.vertical {
+                let line_height = font_size * 1.2;
+                let label_len = btn_label.chars().count() as f32;
+                let total_h = label_len * line_height;
+                let start_y = r.1 + (r.3 - total_h) / 2.0;
+                let char_w = TextLabel::estimate_width("o", font_size);
+                let x_pos = r.0 + (r.2 - char_w) / 2.0;
+                for (char_idx, c) in btn_label.chars().enumerate() {
+                    labels.push(TextLabel {
+                        text: c.to_string(),
+                        x: x_pos,
+                        y: start_y + char_idx as f32 * line_height,
+                        font_size,
+                        color,
+                    });
+                }
+            } else {
+                let est_w = TextLabel::estimate_width(btn_label, font_size);
+                labels.push(TextLabel {
+                    text: btn_label.clone(),
+                    x: r.0 + (r.2 - est_w) / 2.0,
+                    y: r.1 + (r.3 - font_size) / 2.0 - 1.0,
+                    font_size,
+                    color,
+                });
+            }
+        }
+        labels
+    }
+
+    fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
+        if event.state != ElementState::Pressed { return false; }
+        if self.buttons.is_empty() { return false; }
+
+        let current = self.selected.unwrap_or(0);
+        let mut next = current;
+
+        match event.logical_key {
+            Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowUp) => {
+                if current > 0 {
+                    next = current - 1;
+                } else {
+                    next = self.buttons.len() - 1;
+                }
+            }
+            Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowDown) => {
+                if current + 1 < self.buttons.len() {
+                    next = current + 1;
+                } else {
+                    next = 0;
+                }
+            }
+            _ => return false,
+        }
+
+        if Some(next) != self.selected {
+            self.selected = Some(next);
+            self.just_clicked = Some(next);
+            return true;
+        }
+        false
+    }
+}
+
+impl Control for ButtonStrip {}
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index 968219e..0a71b78 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -8,6 +8,7 @@ pub mod dropdown;
 pub mod text_box;
 pub mod trackpad;
 pub mod font_selector;
+pub mod button_strip;
 
 pub use canvas::Canvas;
 pub use button::{Button, ButtonKind, PageButton};
@@ -19,6 +20,7 @@ pub use dropdown::Dropdown;
 pub use text_box::{TextBox, get_font_db};
 pub use trackpad::{Trackpad, Finger};
 pub use font_selector::FontSelector;
+pub use button_strip::ButtonStrip;
 
 pub const BREADCRUMB_PADDING: f32 = 8.0;
 pub const SEGMENT_GAP: f32 = 4.0;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index bc1ef2f..edaa801 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -492,6 +492,9 @@ pub trait Element {
     fn color_u8(&self) -> Option<[u8; 4]> { None }
     fn update_bounds(&mut self, _count: usize, _viewport_y: f32, _viewport_h: f32) {}
     fn get_item_draw_y(&self, _idx: usize, _offset: f32) -> Option<f32> { None }
+
+    fn take_context_change(&mut self) -> Option<usize> { None }
+    fn set_context_selected(&mut self, _selected: usize) {}
 }
 
 pub trait Control: Element {
@@ -533,7 +536,8 @@ pub use self::editor::TextEditorState;
 pub use self::core::{Widget, focus, hover_animation, popovers, clipboard, context_menu};
 pub use self::input::{
     Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
-    ColorSelector, Finger, Trackpad, Canvas, get_font_db, ActiveThumb, FontSelector
+    ColorSelector, Finger, Trackpad, Canvas, get_font_db, ActiveThumb, FontSelector,
+    ButtonStrip
 };
 pub use self::container::{
     Container, Header, ContentBg, ViewportBg, ParametersBg, ScrollingList,