Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/contribution-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Shared vocabulary used throughout the flow below: `<type>` and `<scope>` placeho

### 1. Open the Issue

If the project has issue templates (`.github/ISSUE_TEMPLATE/`), pick the appropriate one. Otherwise use a concise description with acceptance criteria. Fill it into `/tmp/issue-body.md`, then:
Pick the appropriate issue template from `.github/ISSUE_TEMPLATE/` — using a template is **mandatory**. Fill it into `/tmp/issue-body.md`, then:

```bash
gh issue create --title "<type>: ..." --label "<appropriate label>" --body-file /tmp/issue-body.md
Expand Down Expand Up @@ -61,7 +61,7 @@ Multiple commits are fine — they are squashed on merge.

### 6. Push & Open the PR

If the project has a PR template (`.github/PULL_REQUEST_TEMPLATE.md`), fill it into `/tmp/pr-body.md`. Otherwise write a concise summary with a test plan. Then:
Fill in the PR template from `.github/PULL_REQUEST_TEMPLATE.md` into `/tmp/pr-body.md` — using the template is **mandatory**. Then:

```bash
git push -u origin HEAD
Expand Down
1 change: 0 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,5 @@ Closes #

- [ ] PR title follows Conventional Commits
- [ ] No hardcoded user-facing strings — i18n keys added to all locale files
- [ ] New UI components use `gpui-component`
- [ ] Errors defined with `thiserror` (no manual `impl Display/Error`)
- [ ] No unrelated changes mixed in
3 changes: 2 additions & 1 deletion assets/locales/en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ language_name = "English"
# Application

# Tray menu
tray_show = "Show"
tray_settings = "Settings"
tray_about = "About"
tray_quit = "Quit"

# Main window
Expand Down
3 changes: 2 additions & 1 deletion assets/locales/ja.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ language_name = "日本語"
# Application

# Tray menu
tray_show = "表示"
tray_settings = "設定"
tray_about = "について"
tray_quit = "終了"

# Main window
Expand Down
3 changes: 2 additions & 1 deletion assets/locales/zh-CN.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ language_name = "简体中文"
# 应用程序

# 托盘菜单
tray_show = "显示"
tray_settings = "设置"
tray_about = "关于"
tray_quit = "退出"

# 主窗口
Expand Down
2 changes: 2 additions & 0 deletions src/gui/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ pub(crate) struct RopyBoard {
pub(crate) confirm_mode: ConfirmMode,
pub(crate) layout_mode: LayoutMode,
pub(crate) pinned: bool,
pub(crate) activated: bool,
pub(crate) hotkey_tx: Option<async_channel::Sender<String>>,
pub(crate) update_manager: UpdateManager,
/// Which clear action is currently awaiting confirmation
Expand Down Expand Up @@ -479,6 +480,7 @@ impl RopyBoard {
confirm_mode,
layout_mode,
pinned: false,
activated: false,
hotkey_tx: None,
update_manager: UpdateManager::new(),
clear_confirm_action: ClearConfirmAction::AllHistory,
Expand Down
1 change: 1 addition & 0 deletions src/gui/board/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ impl RopyBoard {
self.reveal_selected_record();
self.active_panel = ActivePanel::ClipboardList;
self.ui_state.clear_confirm = crate::gui::board::ClearConfirmState::Hidden;
self.activated = true;
reset_window_geometry_for_activation(window, default_window_size());
active_window(window, cx);
}
Expand Down
8 changes: 8 additions & 0 deletions src/gui/board/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ impl Render for RopyBoard {
.px_4()
.pb_4();

if !self.activated {
return gpui::div()
.size_full()
.child(base.bg(self.main_panel_surface(cx.theme().background)))
.into_any_element();
}

let body: AnyElement = match self.active_panel {
ActivePanel::Settings => base
.bg(self.main_panel_surface(cx.theme().background))
Expand Down Expand Up @@ -80,5 +87,6 @@ impl Render for RopyBoard {
.children(notifs),
)
})
.into_any_element()
}
}
114 changes: 80 additions & 34 deletions src/gui/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use tray_icon::{
use crate::{constants::APP_NAME, i18n::I18n};

/// Fixed menu IDs so event handlers remain valid after menu rebuilds.
const TRAY_SHOW_ID: &str = "tray_show";
const TRAY_SETTINGS_ID: &str = "tray_settings";
const TRAY_ABOUT_ID: &str = "tray_about";
const TRAY_QUIT_ID: &str = "tray_quit";
const TRAY_ICON_SIZE: u32 = 32;

Expand Down Expand Up @@ -62,20 +63,23 @@ impl TrayState {
}

pub(crate) fn build_tray_menu(i18n: &I18n) -> Result<Menu, Box<dyn std::error::Error>> {
let show_item = MenuItem::with_id(TRAY_SHOW_ID, i18n.t("tray_show"), true, None);
let settings_item = MenuItem::with_id(TRAY_SETTINGS_ID, i18n.t("tray_settings"), true, None);
let about_item = MenuItem::with_id(TRAY_ABOUT_ID, i18n.t("tray_about"), true, None);
let quit_item = MenuItem::with_id(TRAY_QUIT_ID, i18n.t("tray_quit"), true, None);

let tray_menu = Menu::new();
tray_menu.append(&show_item)?;
tray_menu.append(&settings_item)?;
tray_menu.append(&about_item)?;
tray_menu.append(&quit_item)?;
Ok(tray_menu)
}

pub(crate) fn init_tray(
i18n: &I18n,
) -> Result<(TrayIcon, MenuId, MenuId), Box<dyn std::error::Error>> {
) -> Result<(TrayIcon, MenuId, MenuId, MenuId), Box<dyn std::error::Error>> {
let tray_menu = build_tray_menu(i18n)?;
let show_id = MenuId::new(TRAY_SHOW_ID);
let settings_id = MenuId::new(TRAY_SETTINGS_ID);
let about_id = MenuId::new(TRAY_ABOUT_ID);
let quit_id = MenuId::new(TRAY_QUIT_ID);

let icon = create_icon()?;
Expand All @@ -87,7 +91,7 @@ pub(crate) fn init_tray(
.with_menu_on_left_click(false)
.build()?;

Ok((tray, show_id, quit_id))
Ok((tray, settings_id, about_id, quit_id))
}

fn create_icon() -> Result<Icon, Box<dyn std::error::Error>> {
Expand Down Expand Up @@ -122,7 +126,8 @@ fn load_tray_icon_rgba(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32), image::Image

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TrayEvent {
Show,
Settings,
About,
Quit,
}

Expand Down Expand Up @@ -178,8 +183,11 @@ fn spawn_tray_event_loop(
cx.spawn(async move |async_app| {
while let Ok(event) = rx.recv().await {
match event {
TrayEvent::Show => {
let _ = async_app.update(|cx| send_active_action(window_handle, cx));
TrayEvent::Settings => {
let _ = async_app.update(|cx| send_open_settings(window_handle, cx));
}
TrayEvent::About => {
let _ = async_app.update(|cx| send_open_about(window_handle, cx));
}
TrayEvent::Quit => {
let _ = async_app.update(|cx| cx.quit());
Expand All @@ -195,12 +203,12 @@ pub(crate) fn start_tray_handler_inner(
tx: async_channel::Sender<TrayEvent>,
) -> Option<TrayIcon> {
match init_tray(i18n) {
Ok((tray, show_id, quit_id)) => {
Ok((tray, settings_id, about_id, quit_id)) => {
tracing::info!("tray icon initialized successfully");

#[cfg(not(target_os = "linux"))]
spawn_tray_icon_event_forwarder(tx.clone());
spawn_tray_menu_event_forwarder(tx, show_id, quit_id);
spawn_tray_menu_event_forwarder(tx, settings_id, about_id, quit_id);

Some(tray)
}
Expand All @@ -211,23 +219,64 @@ pub(crate) fn start_tray_handler_inner(
}
}

pub(crate) fn send_active_action(window_handle: WindowHandle<Root>, cx: &mut App) {
pub(crate) fn send_open_settings(window_handle: WindowHandle<Root>, cx: &mut App) {
window_handle
.update(cx, |root, window: &mut gpui::Window, cx| {
if let Ok(board) = root
.view()
.clone()
.downcast::<crate::gui::board::RopyBoard>()
{
board.update(cx, |board, cx| {
board.active_panel = crate::gui::board::ActivePanel::Settings;
board
.settings_editor
.panel_state
.window_opacity_slider_visible = true;
board.activated = true;
window.focus(&board.focus_handle);
cx.notify();
});
}
super::active_window(window, cx);
})
.ok();
}

pub(crate) fn send_open_about(window_handle: WindowHandle<Root>, cx: &mut App) {
window_handle
.update(cx, |_, window: &mut gpui::Window, cx| {
window.dispatch_action(Box::new(crate::gui::board::Active), cx);
.update(cx, |root, window: &mut gpui::Window, cx| {
if let Ok(board) = root
.view()
.clone()
.downcast::<crate::gui::board::RopyBoard>()
{
board.update(cx, |board, cx| {
board.active_panel = crate::gui::board::ActivePanel::About;
board.activated = true;
cx.notify();
});
}
super::active_window(window, cx);
})
.ok();
}

fn spawn_tray_menu_event_forwarder(
tx: async_channel::Sender<TrayEvent>,
show_id: MenuId,
settings_id: MenuId,
about_id: MenuId,
quit_id: MenuId,
) {
let receiver = tray_icon::menu::MenuEvent::receiver().clone();
super::utils::spawn_event_forwarder("tray-menu-event-forwarder", tx, move |forward| {
while let Ok(event) = receiver.recv() {
if !forward(tray_event_from_menu_event(&event, &show_id, &quit_id)) {
if !forward(tray_event_from_menu_event(
&event,
&settings_id,
&about_id,
&quit_id,
)) {
break;
}
}
Expand All @@ -248,11 +297,14 @@ fn spawn_tray_icon_event_forwarder(tx: async_channel::Sender<TrayEvent>) {

fn tray_event_from_menu_event(
event: &tray_icon::menu::MenuEvent,
show_id: &MenuId,
settings_id: &MenuId,
about_id: &MenuId,
quit_id: &MenuId,
) -> Option<TrayEvent> {
if event.id == *show_id {
Some(TrayEvent::Show)
if event.id == *settings_id {
Some(TrayEvent::Settings)
} else if event.id == *about_id {
Some(TrayEvent::About)
} else if event.id == *quit_id {
Some(TrayEvent::Quit)
} else {
Expand All @@ -265,7 +317,7 @@ fn tray_event_from_icon_event(event: &TrayIconEvent) -> Option<TrayEvent> {
if let TrayIconEvent::Click { button, .. } = event
&& *button == tray_icon::MouseButton::Left
{
Some(TrayEvent::Show)
Some(TrayEvent::Settings)
} else {
None
}
Expand Down Expand Up @@ -321,27 +373,24 @@ mod tests {
}

#[rstest]
#[case(TRAY_SHOW_ID, Some(TrayEvent::Show))]
#[case(TRAY_SETTINGS_ID, Some(TrayEvent::Settings))]
#[case(TRAY_ABOUT_ID, Some(TrayEvent::About))]
#[case(TRAY_QUIT_ID, Some(TrayEvent::Quit))]
#[case("other", None)]
fn test_tray_event_from_menu_event_matches_menu_id(
#[case] event_id: &str,
#[case] expected: Option<TrayEvent>,
) {
let show_id = MenuId::new(TRAY_SHOW_ID);
let settings_id = MenuId::new(TRAY_SETTINGS_ID);
let about_id = MenuId::new(TRAY_ABOUT_ID);
let quit_id = MenuId::new(TRAY_QUIT_ID);
let event = tray_icon::menu::MenuEvent {
id: MenuId::new(event_id),
};

let result = tray_event_from_menu_event(&event, &show_id, &quit_id);
let result = tray_event_from_menu_event(&event, &settings_id, &about_id, &quit_id);

assert!(matches!(
(result, expected),
(Some(TrayEvent::Show), Some(TrayEvent::Show))
| (Some(TrayEvent::Quit), Some(TrayEvent::Quit))
| (None, None)
));
assert_eq!(result, expected);
}

#[cfg(not(target_os = "linux"))]
Expand All @@ -354,7 +403,7 @@ mod tests {
button: tray_icon::MouseButton::Left,
button_state: tray_icon::MouseButtonState::Up,
},
Some(TrayEvent::Show)
Some(TrayEvent::Settings)
)]
#[case(
TrayIconEvent::Click {
Expand All @@ -380,9 +429,6 @@ mod tests {
) {
let result = tray_event_from_icon_event(&event);

assert!(matches!(
(result, expected),
(Some(TrayEvent::Show), Some(TrayEvent::Show)) | (None, None)
));
assert_eq!(result, expected);
}
}
6 changes: 3 additions & 3 deletions src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,19 @@ mod tests {
let i18n = I18n::new(Language::new("en"));
assert!(i18n.is_ok());
let i18n = i18n.unwrap();
assert_eq!(i18n.t("tray_show"), "Show");
assert_eq!(i18n.t("tray_settings"), "Settings");
}

#[test]
#[expect(clippy::unwrap_used)]
fn test_i18n_language_switch() {
let mut i18n = I18n::new(Language::new("en")).unwrap();
assert_eq!(i18n.t("tray_show"), "Show");
assert_eq!(i18n.t("tray_settings"), "Settings");

// Switch to Chinese
let result = i18n.set_language(Language::new("zh-CN"));
assert!(result.is_ok());
assert_eq!(i18n.t("tray_show"), "显示");
assert_eq!(i18n.t("tray_settings"), "设置");
}

#[test]
Expand Down
Loading