Skip to content

Commit 3017700

Browse files
feat: layout button (#24)
Co-authored-by: mosamadeeb <mosamaeldeeb@gmail.com>
1 parent 7cca1a5 commit 3017700

10 files changed

Lines changed: 325 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Add button to show active workspace layout and click to cycle through available layouts.
8+
59
### Changed
610

711
- Active workspace indicator will no longer appear on top of the switcher button and will always be at the bottom of it.

src/komorebi/client.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,20 @@ impl<T> MaybeRingOrVec<T> {
2727
}
2828
}
2929

30+
#[derive(Debug, Deserialize, Clone)]
31+
pub struct KLayout {
32+
#[serde(rename = "Default")]
33+
pub default: String,
34+
}
35+
3036
#[derive(Debug, Deserialize, Clone)]
3137
pub struct KWorkspace {
3238
pub name: Option<String>,
3339
pub containers: Ring<serde_json::Value>,
3440
pub maximized_window: Option<serde_json::Value>,
3541
pub monocle_container: Option<serde_json::Value>,
3642
pub floating_windows: MaybeRingOrVec<serde_json::Value>,
43+
pub layout: KLayout,
3744
}
3845

3946
impl KWorkspace {
@@ -83,12 +90,32 @@ pub struct KState {
8390
pub monitors: Ring<KMonitor>,
8491
}
8592

93+
#[derive(Debug, strum::Display, Serialize, Deserialize)]
94+
pub enum KStateQuery {
95+
FocusedMonitorIndex,
96+
FocusedWorkspaceIndex,
97+
FocusedContainerIndex,
98+
FocusedWindowIndex,
99+
FocusedWorkspaceName,
100+
FocusedWorkspaceLayout, // We can use this to get the layout
101+
FocusedContainerKind,
102+
Version,
103+
}
104+
105+
#[derive(Debug, strum::Display, Serialize, Deserialize)]
106+
pub enum KCycleDirection {
107+
Previous,
108+
Next,
109+
}
110+
86111
#[derive(Debug, strum::Display, Serialize, Deserialize)]
87112
#[serde(tag = "type", content = "content")]
88113
pub enum KSocketMessage {
89114
State,
90115
AddSubscriberSocket(String),
91116
FocusMonitorWorkspaceNumber(usize, usize),
117+
CycleLayout(KCycleDirection),
118+
Query(KStateQuery),
92119
}
93120

94121
#[derive(Debug, strum::Display, Serialize, Deserialize)]
@@ -101,6 +128,7 @@ pub enum KSocketEvent {
101128
FocusWorkspaceNumbers,
102129
CycleFocusMonitor,
103130
CycleFocusWorkspace,
131+
CycleLayout,
104132
ReloadConfiguration,
105133
ReplaceConfiguration,
106134
CompleteConfiguration,

src/komorebi/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use std::time::Duration;
33

44
use client::*;
55

6+
pub use crate::komorebi::client::KCycleDirection as CycleDirection;
7+
68
mod client;
79

810
#[derive(Debug, Clone, Default, Copy)]
@@ -44,6 +46,7 @@ pub struct Workspace {
4446
pub index: usize,
4547
pub focused: bool,
4648
pub is_empty: bool,
49+
pub layout: String,
4750
}
4851

4952
#[derive(Debug, Clone, Default)]
@@ -57,6 +60,10 @@ pub struct Monitor {
5760
}
5861

5962
impl Monitor {
63+
pub fn focused_workspace(&self) -> Option<&Workspace> {
64+
self.workspaces.iter().find(|ws| ws.focused)
65+
}
66+
6067
fn from(monitor: KMonitor, index: usize) -> Self {
6168
let workspaces = monitor
6269
.workspaces
@@ -71,6 +78,7 @@ impl Monitor {
7178
.name
7279
.clone()
7380
.unwrap_or_else(|| (idx + 1).to_string()),
81+
layout: workspace.layout.default.clone(),
7482
})
7583
.collect();
7684

@@ -134,6 +142,15 @@ pub fn change_workspace(monitor_idx: usize, workspace_idx: usize) {
134142
}
135143
}
136144

145+
pub fn cycle_layout(direction: CycleDirection) {
146+
tracing::info!("Changing to {direction} komorebi layout");
147+
148+
let change_msg = KSocketMessage::CycleLayout(direction);
149+
if let Err(e) = client::send_message(&change_msg) {
150+
tracing::error!("Failed to change layout: {e}")
151+
}
152+
}
153+
137154
#[cfg(debug_assertions)]
138155
const SOCK_NAME: &str = "komorebi-switcher-debug.sock";
139156
#[cfg(not(debug_assertions))]

src/macos/layout_button.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
use std::cell::{Cell, RefCell};
2+
3+
use objc2::rc::Retained;
4+
use objc2::{define_class, msg_send, sel, AnyThread, DefinedClass, MainThreadOnly};
5+
use objc2_app_kit::{NSButton, NSColor, NSEvent, NSTrackingArea, NSTrackingAreaOptions};
6+
use objc2_foundation::{MainThreadMarker, NSObjectProtocol, NSString};
7+
8+
use crate::komorebi::CycleDirection;
9+
10+
#[derive(Debug)]
11+
pub struct LayoutButtonIvars {
12+
workspace: crate::komorebi::Workspace,
13+
is_hovering: Cell<bool>,
14+
tracking_area: RefCell<Option<Retained<NSTrackingArea>>>,
15+
}
16+
17+
impl LayoutButtonIvars {
18+
fn new(workspace: crate::komorebi::Workspace) -> Self {
19+
Self {
20+
workspace,
21+
is_hovering: Cell::new(false),
22+
tracking_area: RefCell::new(None),
23+
}
24+
}
25+
}
26+
27+
define_class!(
28+
/// A Custom button representing a workspace in the status bar.
29+
/// Displays an indicator for focused and non-empty workspaces.
30+
#[unsafe(super = NSButton)]
31+
#[thread_kind = MainThreadOnly]
32+
#[ivars = LayoutButtonIvars]
33+
#[derive(Debug)]
34+
pub struct LayoutButton;
35+
36+
unsafe impl NSObjectProtocol for LayoutButton {}
37+
38+
impl LayoutButton {
39+
#[unsafe(method(buttonClicked:))]
40+
fn button_clicked(&self, _sender: &NSButton) {
41+
crate::komorebi::cycle_layout(CycleDirection::Next);
42+
}
43+
44+
#[unsafe(method(mouseEntered:))]
45+
fn mouse_entered(&self, _event: &NSEvent) {
46+
self.ivars().is_hovering.set(true);
47+
let layer = self.layer().unwrap();
48+
let hover_color = NSColor::colorWithWhite_alpha(1.0, 0.1).CGColor();
49+
let _: () = unsafe { msg_send![&layer, setBackgroundColor: &*hover_color] };
50+
}
51+
52+
#[unsafe(method(mouseExited:))]
53+
fn mouse_exited(&self, _event: &NSEvent) {
54+
if self.ivars().workspace.focused {
55+
return;
56+
}
57+
58+
self.ivars().is_hovering.set(false);
59+
let layer = self.layer().unwrap();
60+
let clear_color = NSColor::clearColor().CGColor();
61+
let _: () = unsafe { msg_send![&layer, setBackgroundColor: &*clear_color] };
62+
}
63+
64+
#[unsafe(method(updateTrackingAreas))]
65+
fn update_tracking_areas(&self) {
66+
// Remove old tracking area if it exists
67+
if let Some(old_tracking_area) = self.ivars().tracking_area.borrow().as_ref() {
68+
self.removeTrackingArea(old_tracking_area);
69+
}
70+
71+
// Create new tracking area
72+
let options = NSTrackingAreaOptions::MouseEnteredAndExited
73+
| NSTrackingAreaOptions::ActiveAlways;
74+
let tracking_area = unsafe {
75+
NSTrackingArea::initWithRect_options_owner_userInfo(
76+
NSTrackingArea::alloc(),
77+
self.bounds(),
78+
options,
79+
Some(self),
80+
None,
81+
)
82+
};
83+
self.addTrackingArea(&tracking_area);
84+
85+
// Store tracking area
86+
*self.ivars().tracking_area.borrow_mut() = Some(tracking_area);
87+
}
88+
}
89+
);
90+
91+
impl LayoutButton {
92+
pub fn new(mtm: MainThreadMarker, workspace: &crate::komorebi::Workspace) -> Retained<Self> {
93+
// Create button
94+
let this = Self::alloc(mtm).set_ivars(LayoutButtonIvars::new(workspace.clone()));
95+
// SAFETY: The signature of `NSButton`'s `init` method is correct.
96+
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
97+
98+
// Configure button
99+
this.setTitle(&NSString::from_str(&workspace.layout));
100+
101+
// Set up action handler
102+
unsafe { this.setTarget(Some(&this)) };
103+
unsafe { this.setAction(Some(sel!(buttonClicked:))) };
104+
105+
// Make button transparent
106+
this.setBordered(false);
107+
this.setWantsLayer(true);
108+
let layer = this.layer().unwrap();
109+
let _: () = unsafe { msg_send![&layer, setCornerRadius: 4.0] };
110+
111+
// Add size constraints for padding (since bezel is removed)
112+
let height_constraint = this
113+
.heightAnchor()
114+
.constraintGreaterThanOrEqualToConstant(24.0);
115+
height_constraint.setActive(true);
116+
117+
// Set background color based on focus state
118+
let bg_color = NSColor::clearColor().CGColor();
119+
let _: () = unsafe { msg_send![&layer, setBackgroundColor: &*bg_color] };
120+
121+
this
122+
}
123+
}

src/macos/mod.rs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,35 @@ use objc2::runtime::ProtocolObject;
66
use objc2::{define_class, msg_send, DefinedClass, MainThreadOnly};
77
use objc2_app_kit::{
88
NSApp, NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSStatusBar,
9-
NSStatusItem, NSUserInterfaceLayoutOrientation, NSVariableStatusItemLength,
9+
NSStatusItem, NSTextAlignment, NSTextField, NSUserInterfaceLayoutOrientation,
10+
NSVariableStatusItemLength, NSView,
1011
};
1112
use objc2_foundation::{
12-
MainThreadMarker, NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize,
13+
ns_string, MainThreadMarker, NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect,
14+
NSSize,
1315
};
1416

1517
use self::workspace_button::WorkspaceButton;
1618
use self::workspaces_stack_view::WorkspacesStackView;
19+
use crate::macos::layout_button::LayoutButton;
1720

21+
mod layout_button;
1822
mod workspace_button;
1923
mod workspaces_stack_view;
2024

2125
#[derive(Debug)]
2226
pub struct AppDelegateIvars {
2327
ns_status_item: OnceCell<Retained<NSStatusItem>>,
2428
ns_stack_view: OnceCell<Retained<WorkspacesStackView>>,
25-
workspace_buttons: RefCell<Vec<Retained<WorkspaceButton>>>,
29+
buttons: RefCell<Vec<Retained<NSView>>>,
2630
}
2731

2832
impl Default for AppDelegateIvars {
2933
fn default() -> Self {
3034
Self {
3135
ns_status_item: OnceCell::new(),
3236
ns_stack_view: OnceCell::new(),
33-
workspace_buttons: RefCell::new(Vec::new()),
37+
buttons: RefCell::new(Vec::new()),
3438
}
3539
}
3640
}
@@ -126,17 +130,17 @@ impl AppDelegate {
126130
let mtm = self.mtm();
127131
// SAFETY: We have initialized these ivars in `did_finish_launching`.
128132
let stack_view = self.ivars().ns_stack_view.get().unwrap();
129-
let mut workspace_buttons = self.ivars().workspace_buttons.borrow_mut();
133+
let mut views = self.ivars().buttons.borrow_mut();
130134

131135
// Remove all existing buttons from stack view
132-
for button in workspace_buttons.iter() {
136+
for button in views.iter() {
133137
button.removeFromSuperview();
134138
}
135139

136-
workspace_buttons.clear();
140+
views.clear();
137141

138142
// Get first monitor (we only support one for now)
139-
let Some(monitor) = state.monitors.get(0) else {
143+
let Some(monitor) = state.monitors.first() else {
140144
return;
141145
};
142146

@@ -146,7 +150,23 @@ impl AppDelegate {
146150
stack_view.addArrangedSubview(&workspace_button);
147151

148152
// Store button
149-
workspace_buttons.push(workspace_button);
153+
views.push(workspace_button.downcast().unwrap());
154+
}
155+
156+
// show layout button for focused workspace
157+
if let Some(focused_ws) = monitor.focused_workspace() {
158+
let separator = NSTextField::labelWithString(ns_string!("|"), mtm);
159+
separator.setAlignment(NSTextAlignment::Center);
160+
stack_view.addArrangedSubview(&separator);
161+
162+
// Store separator
163+
views.push(separator.downcast().unwrap());
164+
165+
let layout_button = LayoutButton::new(mtm, focused_ws);
166+
stack_view.addArrangedSubview(&layout_button);
167+
168+
// Store button
169+
views.push(layout_button.downcast().unwrap());
150170
}
151171

152172
// SAFETY: We have initialized this ivar in `did_finish_launching`.
@@ -155,7 +175,7 @@ impl AppDelegate {
155175
// Update status item button frame to match new stack view size
156176
if let Some(btn) = ns_status_item.button(mtm) {
157177
let fitting_size = stack_view.fittingSize();
158-
let size = NSSize::new(fitting_size.width, fitting_size.height);
178+
let size = NSSize::new(fitting_size.width, WorkspaceButton::HEIGHT);
159179
let frame = NSRect::new(NSPoint::new(0.0, 0.0), size);
160180
stack_view.setFrame(frame);
161181
btn.setFrame(frame);

src/macos/workspace_button.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ define_class!(
9090

9191
impl WorkspaceButton {
9292
const INDICATOR_SIZE: f64 = 4.0;
93+
pub const HEIGHT: f64 = 24.0;
9394

9495
pub fn new(mtm: MainThreadMarker, workspace: &crate::komorebi::Workspace) -> Retained<Self> {
9596
// Create button
@@ -117,7 +118,7 @@ impl WorkspaceButton {
117118
.constraintGreaterThanOrEqualToConstant(32.0);
118119
let height_constraint = this
119120
.heightAnchor()
120-
.constraintGreaterThanOrEqualToConstant(24.0);
121+
.constraintGreaterThanOrEqualToConstant(Self::HEIGHT);
121122
width_constraint.setActive(true);
122123
height_constraint.setActive(true);
123124

src/macos/workspaces_stack_view.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use objc2::rc::Retained;
22
use objc2::{define_class, msg_send, sel, MainThreadOnly};
3-
use objc2_app_kit::{NSEvent, NSMenu, NSMenuItem, NSStackView};
3+
use objc2_app_kit::{NSEvent, NSLayoutAttribute, NSMenu, NSMenuItem, NSStackView};
44
use objc2_foundation::{MainThreadMarker, NSString};
55

66
define_class!(
@@ -65,6 +65,10 @@ impl WorkspacesStackView {
6565
pub fn new(mtm: MainThreadMarker) -> Retained<Self> {
6666
let this = Self::alloc(mtm);
6767
// SAFETY: The signature of `NSStackView`'s `init` method is correct.
68-
unsafe { msg_send![this, init] }
68+
let this: Retained<Self> = unsafe { msg_send![this, init] };
69+
70+
this.setAlignment(NSLayoutAttribute::CenterY);
71+
72+
this
6973
}
7074
}

0 commit comments

Comments
 (0)