diff --git a/CLAUDE.md b/CLAUDE.md index 146f251..ac13300 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ HUD を実機で見るなら `./scripts/local_check.sh`(オプションは `do ```bash ./scripts/visual_regression.sh --update ``` -- VRT が撮るのは HUD の contentView と設定ウィンドウの各ペイン(`run_settings_case`。タブバーは contentView の外なので写らない)。メニューバーと About パネルは対象外(システム所有の表面で view を PNG 化できない。About は渡す中身を UT で固定している) +- VRT が撮るのは HUD の contentView、設定ウィンドウの各ペイン、設定タブの行スタック全体(`run_settings_case`。ペインはタブバーが contentView の外なので写らず、行スタック全体は `NSScrollView` の documentView を可視域でクリップせず撮る)。メニューバーと About パネルは対象外(システム所有の表面で view を PNG 化できない。About は渡す中身を UT で固定している) - VRT のケースは `CLIIP_SHOW_*` 環境変数で設定を上書きして作る(`scripts/visual_regression.sh` の `run_case`)。設定ウィンドウのケースは `CLIIP_SHOW_LANGUAGE` を必ず明示する(`auto` は実行マシンの言語に依存し、ローカルと CI でベースラインが食い違う) - `Cargo.toml` の `edition` を変えたら `.claude/settings.json` の rustfmt フック(`--edition` を直書き)も同期する。ズレるとフックの整形結果を CI の `cargo fmt --check` が拒否する diff --git a/README.ja.md b/README.ja.md index 9f62687..b7d2062 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,6 +47,8 @@ Apple の Developer ID で署名していないため、cask のインストー 起動中にもう一度起動すると、2 つ目は立ち上がらず設定ウィンドウが開きます。 +設定ウィンドウの「メニューバーにアイコンを表示」でメニューバーのアイコンを非表示にできます。非表示にするとメニュー(設定・一時停止・終了)にも触れなくなりますが、アプリをもう一度起動すると設定ウィンドウが前面に出るので、そこから再び表示できます。 + ### formula から乗り換える formula は cask に置き換わったので、先にアンインストールします。 diff --git a/README.md b/README.md index a9bb57c..d6bf68a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Launching it shows "Cliip Show started" in the HUD. It stays quiet when launched Launching it again while it is running opens the settings window instead of starting a second copy. +You can hide the menu bar icon from "Show icon in menu bar" in the settings window. If you do, that also cuts off the menu (settings, pause, quit); launch the app again to bring the settings window to the front and show the icon again. + ### Coming from the formula The formula is replaced by the cask, so uninstall it first: diff --git a/docs/development.md b/docs/development.md index 814c121..06b49c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -55,6 +55,7 @@ cliip-show --config-show - `hud_emoji`(既定値: `📋`、任意の絵文字。空でアイコンなし) - `hud_image_max_height`(既定値: `160`、`40` - `240`)画像サムネイルの高さ上限(px)。実際の上限は `hud_scale` 倍され、元画像より大きくは表示しません - `language`(既定値: `auto`、`auto` / `ja` / `en`)メニューバーと設定ウィンドウの表示言語。`auto` は macOS の優先言語に従います +- `show_menu_bar_icon`(既定値: `true`)メニューバーアイコンの表示。`false` にすると、メニューから開く設定・一時停止・終了に触れなくなります。アプリをもう一度起動すると設定ウィンドウが前面に出るので、そこから表示に戻せます 設定キー(`[startup]` 節): - `start_at_login`(既定値: `false`)設定ウィンドウの「ログイン時に自動起動」と同じ値です。LaunchAgent の plist はこの値から書き出すため、plist を手で消してもこの値が残っていれば次の起動で書き直します @@ -72,6 +73,7 @@ CLIIP_SHOW_HUD_BACKGROUND_OPACITY=0.6 \ CLIIP_SHOW_HUD_EMOJI=🍣 \ CLIIP_SHOW_HUD_IMAGE_MAX_HEIGHT=120 \ CLIIP_SHOW_LANGUAGE=en \ +CLIIP_SHOW_SHOW_MENU_BAR_ICON=true \ cargo run ``` @@ -137,6 +139,7 @@ HUDと設定ウィンドウの各ペインの描画結果をPNGで比較しま - デフォルト設定での表示 - 設定プロファイルごとの表示(例: `max_lines=2`, `max_chars_per_line=24`) - 設定ウィンドウのペイン(設定・寄付 × 日本語・英語。タブバーはウィンドウのツールバー領域にあるため写りません) +- 設定タブの行スタック全体(`NSScrollView` の documentView。可視域でクリップされず全行が1枚に収まります。フッターのボタン列は documentView の外にあるため写りません) ### 生成物 diff --git a/scripts/visual_regression.sh b/scripts/visual_regression.sh index 8db41fc..6b51613 100755 --- a/scripts/visual_regression.sh +++ b/scripts/visual_regression.sh @@ -57,6 +57,7 @@ render_and_compare() { -u CLIIP_SHOW_HUD_IMAGE_MAX_HEIGHT -u CLIIP_SHOW_HUD_EMOJI -u CLIIP_SHOW_LANGUAGE + -u CLIIP_SHOW_SHOW_MENU_BAR_ICON "CLIIP_SHOW_CONFIG_PATH=$VRT_CONFIG_PATH" ) if [[ $# -gt 0 ]]; then @@ -143,8 +144,9 @@ run_image_case() { render_and_compare "$id" --render-hud-png --image-fixture "$size" "$@" } -# 設定ウィンドウのペイン(settings | support)。タブバーは contentView の外なので写らない。 -# 言語は必ず CLIIP_SHOW_LANGUAGE で固定して呼ぶこと(auto は実行マシンの言語に依存する) +# 設定ウィンドウのペイン(settings | settings-rows | support)。タブバーは contentView の外 +# なので写らない。言語は必ず CLIIP_SHOW_LANGUAGE で固定して呼ぶこと(auto は実行マシンの +# 言語に依存する) run_settings_case() { local id="$1" local pane="$2" @@ -365,6 +367,18 @@ run_settings_case \ "settings" \ "CLIIP_SHOW_LANGUAGE=en" +# Settings tab document view: full row stack (NSScrollView documentView), unclipped by the +# visible scroll area, so rows below the fold are captured too. +run_settings_case \ + "settings_rows_ja" \ + "settings-rows" \ + "CLIIP_SHOW_LANGUAGE=ja" + +run_settings_case \ + "settings_rows_en" \ + "settings-rows" \ + "CLIIP_SHOW_LANGUAGE=en" + run_settings_case \ "support_pane_ja" \ "support" \ diff --git a/src/app/config_reload.rs b/src/app/config_reload.rs index d1228cb..df63cb1 100644 --- a/src/app/config_reload.rs +++ b/src/app/config_reload.rs @@ -140,9 +140,26 @@ pub(crate) unsafe fn apply_settings_now(state: &mut AppState, new_settings: Disp super::schedule_poll_timer(state.delegate, new_settings.poll_interval_secs); } + let menu_bar_icon_changed = + new_settings.show_menu_bar_icon != state.settings.show_menu_bar_icon; let language_changed = new_settings.language != state.settings.language; state.settings = new_settings; + // show_menu_bar_icon が変わったら status item の表示と設定ウィンドウのトグルを即時更新。 + // 手編集・環境変数・GUI のトグル操作のいずれもここを通るため、更新箇所は一箇所で済む。 + if menu_bar_icon_changed { + crate::menu::apply_menu_bar_icon_visibility( + state.menu_handles.status.status_item, + state.settings.show_menu_bar_icon, + ); + crate::settings_window::sync_menu_bar_icon_toggle( + &state.settings_controls, + state.settings.show_menu_bar_icon, + ); + // draft は「保存」で丸ごと書き戻されるため、古い値で上書きしないようここでも揃える。 + state.settings_controls.draft.show_menu_bar_icon = state.settings.show_menu_bar_icon; + } + // language が変わったら設定ウィンドウとメニューの文言を即時更新。 // ファイル監視の再読み込みと設定ウィンドウの言語ポップアップのどちらの経路もここを // 通るため、更新箇所は一箇所で済む。 diff --git a/src/app/mod.rs b/src/app/mod.rs index 3365f4b..0833fc8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -111,6 +111,10 @@ pub fn get_delegate_class() -> &'static AnyClass { sel!(toggleLoginItem:), toggle_login_item as extern "C" fn(_, _, _), ); + builder.add_method( + sel!(toggleMenuBarIcon:), + toggle_menu_bar_icon as extern "C" fn(_, _, _), + ); builder.add_method( sel!(showEmojiHelp:), show_emoji_help as extern "C" fn(_, _, _), @@ -155,6 +159,12 @@ extern "C" fn application_did_finish_launching(this: &AnyObject, _: Sel, _: *mut } let status_handles = crate::menu::create_status_item(this, lang); + // NSStatusItem.visible は AppKit 側が永続化するため、差分判定なしで毎回設定を当てる + // (前回の残骸で消えたままになるのを防ぐ)。 + crate::menu::apply_menu_bar_icon_visibility( + status_handles.status_item, + settings.show_menu_bar_icon, + ); let menu_handles = MenuHandles { status: status_handles, edit: edit_handles, @@ -302,8 +312,14 @@ extern "C" fn open_settings(this: &AnyObject, _: Sel, _: *mut AnyObject) { if state.settings_controls.window.is_null() { let lang = i18n::resolve(state.settings.language); - state.settings_controls = - crate::settings_window::build_settings_window(this, lang, state.start_at_login); + state.settings_controls = crate::settings_window::build_settings_window( + this, + lang, + crate::settings_window::InitialToggles { + start_at_login: state.start_at_login, + show_menu_bar_icon: state.settings.show_menu_bar_icon, + }, + ); } // 既に開いているウィンドウは前面に出すだけにする。他のインスタンスの起動でも @@ -413,6 +429,56 @@ fn persist_start_at_login(state: &mut AppState, value: bool) -> bool { true } +/// メニューバーアイコン表示トグルの `toggleMenuBarIcon:`。ログイン項目トグルと同じく下書きを +/// 経由せず、チェックした瞬間に設定ファイルへ保存し `apply_settings_now` で status item・ +/// 設定ウィンドウへ即時反映する。非表示にした場合だけ、復帰方法(アプリを再起動すると +/// 設定ウィンドウが開く)を知らせるダイアログを出す。 +extern "C" fn toggle_menu_bar_icon(_: &AnyObject, _: Sel, sender: *mut AnyObject) { + unsafe { + let hidden_dialog_lang = { + // AppKit メインスレッドからのみ呼ばれるため、Mutex が poison されるケースは実質発生しない + let mut guard = APP_STATE.lock().expect("APP_STATE lock poisoned"); + let Some(state) = guard.as_mut() else { + return; + }; + + let checked: isize = msg_send![sender, state]; + let desired = checked != 0; + let was_visible = state.settings.show_menu_bar_icon; + + match crate::settings_window::save_show_menu_bar_icon(state, desired) { + Ok(new_settings) => { + apply_settings_now(state, new_settings); + // 環境変数が show_menu_bar_icon を固定していると、設定ファイルへ保存しても + // 実効値は動かない。トグルの表示を実効値へ揃え、ダイアログも実際に + // アイコンが消えたときだけ出す。 + let visible = state.settings.show_menu_bar_icon; + crate::settings_window::sync_menu_bar_icon_toggle( + &state.settings_controls, + visible, + ); + (was_visible && !visible).then(|| i18n::resolve(state.settings.language)) + } + Err(error) => { + eprintln!("warning: {error}"); + // 失敗したのにチェックだけ付いた状態を避け、実際の状態に表示を戻す。 + crate::settings_window::sync_menu_bar_icon_toggle( + &state.settings_controls, + state.settings.show_menu_bar_icon, + ); + None + } + } + }; + + // runModal は実行ループを止めるため、APP_STATE のロックを手放した後に呼ぶ + // (prompt_login_item_if_needed と同じ理由)。 + if let Some(lang) = hidden_dialog_lang { + panels::prompt_menu_bar_icon_hidden(lang); + } + } +} + // NSRectEdge: maxX。ボタン/フィールドの右側に popover を出す(`show_emoji_help_popover`・ // `show_range_hint_popover` で共用)。 const NS_RECT_EDGE_MAX_X: usize = 2; @@ -831,6 +897,7 @@ mod tests { sel!(previewSettings:), sel!(saveSettings:), sel!(toggleLoginItem:), + sel!(toggleMenuBarIcon:), sel!(showEmojiHelp:), sel!(closeRangeHintPopover:), sel!(windowWillClose:), diff --git a/src/app/panels.rs b/src/app/panels.rs index f7743f7..9cd10ce 100644 --- a/src/app/panels.rs +++ b/src/app/panels.rs @@ -97,6 +97,33 @@ pub(super) unsafe fn prompt_login_item_if_needed(lang: Lang, start_at_login: boo } } +/// メニューバーのアイコンを非表示にした直後に出す、復帰方法の案内ダイアログ。 +/// ボタンは追加しない(NSAlert の既定である「OK」1つで足りる)。抑止チェックボックスも +/// 持たない。この経路は「今しがた非表示にした」操作の直後にしか出ないため、 +/// `prompt_login_item_if_needed` のように繰り返し表示を避ける仕組みは不要。 +/// +/// # Safety +/// - `APP_STATE` のロックを保持したまま呼ばないこと。`runModal` が実行ループを止めるため、 +/// 他の経路がロック待ちで固まる。 +/// - AppKit のメインスレッドから呼ぶこと。 +pub(super) unsafe fn prompt_menu_bar_icon_hidden(lang: Lang) { + // accessory アプリ(setActivationPolicy: 1)はこれを呼ばないとダイアログが前面に来ない + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + let () = msg_send![app, activateIgnoringOtherApps: true]; + + let alert: *mut AnyObject = msg_send![class!(NSAlert), alloc]; + let alert: *mut AnyObject = msg_send![alert, init]; + + let message = NSString::from_str(i18n::text(lang, Msg::MenuBarIconHiddenMessage)); + let () = msg_send![alert, setMessageText: &*message]; + + let informative = NSString::from_str(i18n::text(lang, Msg::MenuBarIconHiddenDetail)); + let () = msg_send![alert, setInformativeText: &*informative]; + + let _: isize = msg_send![alert, runModal]; + let () = msg_send![alert, release]; +} + /// macOS 標準の About パネルを出す。バンドルを持たないので、表示内容は options で渡す。 pub(super) extern "C" fn show_about_panel(_: &AnyObject, _: Sel, _: *mut AnyObject) { unsafe { diff --git a/src/cli.rs b/src/cli.rs index 47dce21..21ed6cb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -106,10 +106,12 @@ fn parse_settings_png_args(args: impl Iterator) -> Result SettingsPane::Settings, + "settings-rows" => SettingsPane::SettingsRows, "support" => SettingsPane::Support, unknown => { return Err(format!( - "Invalid value for --pane: {unknown} (expected settings or support)" + "Invalid value for --pane: {unknown} \ + (expected settings, settings-rows, or support)" )); } }); @@ -225,7 +227,7 @@ pub fn handle_cli_flags() -> bool { ); let _ = writeln!( help, - " --render-settings-png --pane --output Render settings window pane PNG and exit" + " --render-settings-png --pane --output Render settings window pane PNG and exit" ); let _ = writeln!( help, @@ -305,6 +307,10 @@ pub fn handle_cli_flags() -> bool { help, " CLIIP_SHOW_LANGUAGE UI language (auto|ja|en)" ); + let _ = writeln!( + help, + " CLIIP_SHOW_SHOW_MENU_BAR_ICON Show the menu bar icon (true|false)" + ); print!("{help}"); true } @@ -605,13 +611,21 @@ mod tests { .map(|parsed| parsed.pane), Ok(SettingsPane::Settings) ); + assert_eq!( + parse_settings_png_args(args(&["--pane", "settings-rows", "--output", "/tmp/s.png"])) + .map(|parsed| parsed.pane), + Ok(SettingsPane::SettingsRows) + ); } #[test] fn settings_args_reject_invalid_or_missing_options() { assert_eq!( parse_settings_png_args(args(&["--pane", "about", "--output", "/tmp/s.png"])), - Err("Invalid value for --pane: about (expected settings or support)".to_string()) + Err( + "Invalid value for --pane: about (expected settings, settings-rows, or support)" + .to_string() + ) ); assert_eq!( parse_settings_png_args(args(&["--output", "/tmp/s.png"])), diff --git a/src/config/parse.rs b/src/config/parse.rs index 05b9cf0..0fe79ce 100644 --- a/src/config/parse.rs +++ b/src/config/parse.rs @@ -48,6 +48,15 @@ pub fn parse_hud_background_color(raw: &str) -> Option { } } +/// `"true"`/`"false"`(前後空白は trim)以外は `None`。 +pub fn parse_bool(raw: &str) -> Option { + match raw.trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + pub fn parse_language(raw: &str) -> Option { let normalized = raw.trim().to_ascii_lowercase().replace('-', "_"); match normalized.as_str() { @@ -193,6 +202,10 @@ pub(crate) fn parse_language_setting(raw: &str, default: LanguageSetting) -> Lan parse_language(raw).unwrap_or(default) } +pub(crate) fn parse_bool_setting(raw: &str, default: bool) -> bool { + parse_bool(raw).unwrap_or(default) +} + pub fn set_config_value( config: &mut AppConfigFile, key: ConfigKey, @@ -335,18 +348,20 @@ pub fn set_config_value( ))); } } + ConfigKey::ShowMenuBarIcon => { + let raw = value.trim(); + let parsed = parse_bool(raw).ok_or_else(|| AppError::InvalidValue { + key: "show_menu_bar_icon", + message: format!("{raw} (allowed: true, false)"), + })?; + config.display.show_menu_bar_icon = Some(parsed); + } ConfigKey::StartAtLogin => { let raw = value.trim(); - let parsed = match raw { - "true" => true, - "false" => false, - _ => { - return Err(AppError::InvalidValue { - key: "start_at_login", - message: format!("{raw} (allowed: true, false)"), - }) - } - }; + let parsed = parse_bool(raw).ok_or_else(|| AppError::InvalidValue { + key: "start_at_login", + message: format!("{raw} (allowed: true, false)"), + })?; config.startup.start_at_login = Some(parsed); } } @@ -542,6 +557,16 @@ mod tests { assert_eq!(hud_emoji_validation_error("あ"), Some(Msg::EmojiNotEmoji)); } + #[test] + fn parse_bool_accepts_true_and_false_only() { + assert_eq!(parse_bool("true"), Some(true)); + assert_eq!(parse_bool("false"), Some(false)); + assert_eq!(parse_bool(" true "), Some(true)); + assert_eq!(parse_bool("True"), None); + assert_eq!(parse_bool("1"), None); + assert_eq!(parse_bool(""), None); + } + #[test] fn parse_language_accepts_valid_values() { assert_eq!(parse_language("auto"), Some(LanguageSetting::Auto)); @@ -564,6 +589,22 @@ mod tests { assert!(err.to_string().contains("language")); } + #[test] + fn set_config_value_accepts_show_menu_bar_icon() { + let mut config = AppConfigFile::default(); + let warning = + set_config_value(&mut config, ConfigKey::ShowMenuBarIcon, "false").expect("set value"); + assert_eq!(config.display.show_menu_bar_icon, Some(false)); + assert!(warning.is_none()); + + set_config_value(&mut config, ConfigKey::ShowMenuBarIcon, "true").expect("set value"); + assert_eq!(config.display.show_menu_bar_icon, Some(true)); + + let err = set_config_value(&mut config, ConfigKey::ShowMenuBarIcon, "maybe") + .expect_err("reject invalid value"); + assert!(err.to_string().contains("show_menu_bar_icon")); + } + #[test] fn set_config_value_accepts_start_at_login() { let mut config = AppConfigFile::default(); diff --git a/src/config/settings.rs b/src/config/settings.rs index a038535..407236d 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,8 +1,8 @@ use super::io::{config_file_path, load_config_file}; use super::parse::{ - parse_f64_config_value, parse_f64_setting, parse_hud_background_color_setting, parse_hud_emoji, - parse_hud_position_setting, parse_language_setting, parse_usize_config_value, - parse_usize_setting, + parse_bool_setting, parse_f64_config_value, parse_f64_setting, + parse_hud_background_color_setting, parse_hud_emoji, parse_hud_position_setting, + parse_language_setting, parse_usize_config_value, parse_usize_setting, }; use super::types::HudBackgroundColor; use super::types::{ @@ -33,6 +33,7 @@ pub fn default_display_settings() -> DisplaySettings { hud_emoji: "📋".to_string(), hud_image_max_height: DEFAULT_HUD_IMAGE_MAX_HEIGHT, language: LanguageSetting::default(), + show_menu_bar_icon: true, } } @@ -137,6 +138,9 @@ pub fn apply_config_file(base: DisplaySettings, config: &AppConfigFile) -> Displ if let Some(value) = config.display.language { settings.language = value; } + if let Some(value) = config.display.show_menu_bar_icon { + settings.show_menu_bar_icon = value; + } settings } @@ -226,6 +230,9 @@ fn apply_env_overrides_with( if let Some(value) = read_env_option("CLIIP_SHOW_LANGUAGE") { settings.language = parse_language_setting(&value, settings.language); } + if let Some(value) = read_env_option("CLIIP_SHOW_SHOW_MENU_BAR_ICON") { + settings.show_menu_bar_icon = parse_bool_setting(&value, settings.show_menu_bar_icon); + } settings } @@ -256,6 +263,7 @@ fn saved_value(config: &AppConfigFile, key: ConfigKey) -> Option { ConfigKey::HudEmoji => display.hud_emoji.clone(), ConfigKey::HudImageMaxHeight => display.hud_image_max_height.map(|v| v.to_string()), ConfigKey::Language => display.language.map(|v| v.as_str().to_string()), + ConfigKey::ShowMenuBarIcon => display.show_menu_bar_icon.map(|v| v.to_string()), ConfigKey::StartAtLogin => config.startup.start_at_login.map(|v| v.to_string()), } } @@ -282,6 +290,7 @@ pub fn print_effective_settings(settings: DisplaySettings, start_at_login: bool) println!("hud_emoji = {}", settings.hud_emoji); println!("hud_image_max_height = {}", settings.hud_image_max_height); println!("language = {}", settings.language.as_str()); + println!("show_menu_bar_icon = {}", settings.show_menu_bar_icon); println!("start_at_login = {start_at_login}"); } @@ -307,6 +316,7 @@ pub fn settings_to_config_file(settings: DisplaySettings) -> AppConfigFile { hud_emoji: Some(settings.hud_emoji.clone()), hud_image_max_height: Some(settings.hud_image_max_height), language: Some(settings.language), + show_menu_bar_icon: Some(settings.show_menu_bar_icon), }, // `DisplaySettings` は自動起動の値を持たないため、ここは常に未設定 // (`StartupConfigFile::default()`)。`Some(false)` を返すと、この関数の @@ -499,6 +509,7 @@ mod tests { ConfigKey::HudEmoji => "🍺", ConfigKey::HudImageMaxHeight => "80", ConfigKey::Language => "ja", + ConfigKey::ShowMenuBarIcon => "false", ConfigKey::StartAtLogin => continue, }; let env_name = format!("CLIIP_SHOW_{}", key.as_str().to_uppercase()); diff --git a/src/config/types.rs b/src/config/types.rs index cf026fa..08f86bb 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -77,6 +77,7 @@ pub struct DisplaySettings { pub hud_emoji: String, pub hud_image_max_height: usize, pub language: LanguageSetting, + pub show_menu_bar_icon: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -101,6 +102,7 @@ pub struct DisplayConfigFile { pub hud_emoji: Option, pub hud_image_max_height: Option, pub language: Option, + pub show_menu_bar_icon: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -122,12 +124,13 @@ pub enum ConfigKey { HudEmoji, HudImageMaxHeight, Language, + ShowMenuBarIcon, StartAtLogin, } impl ConfigKey { /// 設定ファイルに並ぶ順。キーを網羅して回りたい箇所はここを使う。 - pub const ALL: [ConfigKey; 13] = [ + pub const ALL: [ConfigKey; 14] = [ ConfigKey::PollIntervalSecs, ConfigKey::HudDurationSecs, ConfigKey::HudFadeDurationSecs, @@ -140,6 +143,7 @@ impl ConfigKey { ConfigKey::HudEmoji, ConfigKey::HudImageMaxHeight, ConfigKey::Language, + ConfigKey::ShowMenuBarIcon, ConfigKey::StartAtLogin, ]; @@ -157,6 +161,7 @@ impl ConfigKey { ConfigKey::HudEmoji => "hud_emoji", ConfigKey::HudImageMaxHeight => "hud_image_max_height", ConfigKey::Language => "language", + ConfigKey::ShowMenuBarIcon => "show_menu_bar_icon", ConfigKey::StartAtLogin => "start_at_login", } } diff --git a/src/i18n.rs b/src/i18n.rs index d5baf70..983420c 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -45,10 +45,14 @@ pub enum Msg { LoginPromptLater, LoginPromptSuppress, + MenuBarIconHiddenMessage, + MenuBarIconHiddenDetail, + SettingsTitle, TabSettings, TabSupport, SettingsStartAtLogin, + SettingsShowMenuBarIcon, LabelPollInterval, LabelHudDuration, LabelHudFadeDuration, @@ -115,10 +119,20 @@ pub fn text(lang: Lang, msg: Msg) -> &'static str { Msg::LoginPromptLater => ("あとで", "Not Now"), Msg::LoginPromptSuppress => ("今後表示しない", "Don’t ask again"), + Msg::MenuBarIconHiddenMessage => ( + "メニューバーのアイコンを非表示にしました", + "The menu bar icon is now hidden", + ), + Msg::MenuBarIconHiddenDetail => ( + "Cliip Show をもう一度起動すると設定ウィンドウが開きます。アイコンはそこから表示に戻せます。", + "Launch Cliip Show again to open the settings window, where you can show the icon again.", + ), + Msg::SettingsTitle => ("Cliip Show 設定", "Cliip Show Settings"), Msg::TabSettings => ("設定", "Settings"), Msg::TabSupport => ("寄付", "Support"), Msg::SettingsStartAtLogin => ("ログイン時に自動起動", "Start at login"), + Msg::SettingsShowMenuBarIcon => ("メニューバーにアイコンを表示", "Show icon in menu bar"), Msg::LabelPollInterval => ("ポーリング間隔(秒)", "Polling interval (sec)"), Msg::LabelHudDuration => ("表示時間(秒)", "Display duration (sec)"), Msg::LabelHudFadeDuration => ("フェード時間(秒)", "Fade duration (sec)"), @@ -345,6 +359,7 @@ mod tests { Msg::MenuAbout, Msg::MenuQuit, Msg::LoginPromptMessage, + Msg::MenuBarIconHiddenDetail, Msg::SettingsTitle, Msg::SupportMessage, Msg::LaunchNotice, diff --git a/src/menu.rs b/src/menu.rs index 80db4a6..099db23 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -245,6 +245,16 @@ pub unsafe fn apply_paused_state( let () = msg_send![button, setAlphaValue: alpha]; } +/// メニューバー常駐アイコンの表示/非表示を切り替える。status item 自体は作り直さない +/// (設定ファイルを正とし、表示状態はその派生物という設計のため)。 +/// +/// # Safety +/// - `status_item` は `create_status_item` が返した有効なポインタであること。 +/// - AppKit のメインスレッドから呼ぶこと。 +pub unsafe fn apply_menu_bar_icon_visibility(status_item: *mut AnyObject, visible: bool) { + let () = msg_send![status_item, setVisible: visible]; +} + /// 言語切り替えで、メニューバー常駐アイコンのメニューとメインメニューの「編集」サブメニューの /// タイトルをすべて差し替える。 /// diff --git a/src/png.rs b/src/png.rs index 596f79c..19a53c2 100644 --- a/src/png.rs +++ b/src/png.rs @@ -102,6 +102,9 @@ pub fn render_hud_image_png( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsPane { Settings, + /// 設定タブの行スタック全体(`NSScrollView` の documentView)。可視域でクリップされず、 + /// スクロール位置に関わらず全行が1枚に収まる。 + SettingsRows, Support, } @@ -116,8 +119,14 @@ pub enum SettingsPane { /// - 実ウィンドウの背景(ここでは決定性のために不透明背景を合成しており、 /// 実アプリの背景まわりの退行はこのスナップショットでは検出できない) /// +/// `SettingsRows` は設定タブの行スタック全体(documentView)を撮る。`Settings` と違い +/// スクロール位置に関わらず全行が写るが、documentView の外にあるフッターのボタン列 +/// (「デフォルトに戻す」「お試し表示」「保存」)は写らない。 +/// /// 自動起動トグルは環境依存の値(LaunchAgent の有無・設定ファイルの内容)に触れず、 -/// ベースラインが環境非依存になるよう OFF 固定で渡す。 +/// ベースラインが環境非依存になるよう OFF 固定で渡す。メニューバーアイコン表示トグルは +/// `sync_controls_from_settings` が実効設定の値で上書きするため、渡す初期値自体は +/// 描画結果に影響しない。 pub fn render_settings_png(pane: SettingsPane, output_path: &str) -> Result<(), AppError> { unsafe { let _app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; @@ -126,7 +135,14 @@ pub fn render_settings_png(pane: SettingsPane, output_path: &str) -> Result<(), // アクションは一切発火させないので、AppState 未初期化の素の delegate でよい let delegate: *mut AnyObject = msg_send![crate::app::get_delegate_class(), new]; - let mut controls = crate::settings_window::build_settings_window(&*delegate, lang, false); + let mut controls = crate::settings_window::build_settings_window( + &*delegate, + lang, + crate::settings_window::InitialToggles { + start_at_login: false, + show_menu_bar_icon: true, + }, + ); if controls.window.is_null() { let () = msg_send![delegate, release]; return Err(AppError::RenderFailed( @@ -137,7 +153,9 @@ pub fn render_settings_png(pane: SettingsPane, output_path: &str) -> Result<(), crate::settings_window::sync_controls_from_settings(&mut controls, &settings); let tab_index: isize = match pane { - SettingsPane::Settings => crate::settings_window::TAB_INDEX_SETTINGS, + SettingsPane::Settings | SettingsPane::SettingsRows => { + crate::settings_window::TAB_INDEX_SETTINGS + } SettingsPane::Support => crate::settings_window::TAB_INDEX_SUPPORT, }; let tab_controller: *mut AnyObject = msg_send![controls.window, contentViewController]; @@ -157,20 +175,25 @@ pub fn render_settings_png(pane: SettingsPane, output_path: &str) -> Result<(), } let () = msg_send![controls.window, setAppearance: aqua]; - // contentView は透明で、ウィンドウ背景は contentView の外にあるため PNG に写らない。 - // ラベルの黒文字が読めるよう、不透明な明色を背景に敷く - let content_view: *mut AnyObject = msg_send![controls.window, contentView]; - let () = msg_send![content_view, setWantsLayer: true]; - let layer: *mut AnyObject = msg_send![content_view, layer]; + // 撮る view(ペインは contentView、行スタック全体は documentView)は透明で、実ウィンドウの + // 背景はその外にあるため PNG に写らない。ラベルの黒文字が読めるよう、不透明な明色を背景に敷く + let target_view: *mut AnyObject = match pane { + SettingsPane::SettingsRows => controls.document_view, + SettingsPane::Settings | SettingsPane::Support => { + msg_send![controls.window, contentView] + } + }; + let () = msg_send![target_view, setWantsLayer: true]; + let layer: *mut AnyObject = msg_send![target_view, layer]; let bg: *mut AnyObject = msg_send![class!(NSColor), colorWithCalibratedWhite: 0.93f64 alpha: 1.0f64]; let cg_color: *mut std::ffi::c_void = msg_send![bg, CGColor]; let () = msg_send![layer, setBackgroundColor: cg_color]; // ウィンドウを画面に出さないため、レイアウトを明示的に確定させる - let () = msg_send![content_view, layoutSubtreeIfNeeded]; + let () = msg_send![target_view, layoutSubtreeIfNeeded]; - let result = write_window_png(controls.window, output_path); + let result = write_view_png(controls.window, target_view, output_path); let () = msg_send![delegate, release]; result } @@ -294,7 +317,7 @@ unsafe fn draw_preview_sample_label(canvas_width: f64, canvas_height: f64) { let () = msg_send![shadow, release]; } -/// ウィンドウの contentView を PNG にして書き出す(HUD・設定ウィンドウ共用)。 +/// ウィンドウの contentView を PNG にして書き出す(HUD・設定ウィンドウのペイン撮影共用)。 /// /// # Safety /// - `window` は有効な NSWindow であること。呼び出し後にクローズされる。 @@ -308,7 +331,23 @@ unsafe fn write_window_png(window: *mut AnyObject, output_path: &str) -> Result< )); } - let bounds: NSRect = msg_send![content_view, bounds]; + write_view_png(window, content_view, output_path) +} + +/// 指定した `view` を PNG にして書き出し、`window` をクローズする。`write_window_png` の +/// 実装本体で、documentView など contentView 以外を撮る撮影(`render_settings_png` の +/// `SettingsRows`)とも共有する。 +/// +/// # Safety +/// - `window` は有効な NSWindow、`view` はその中の有効な NSView であること。 +/// 呼び出し後 `window` はクローズされる。 +/// - AppKit のメインスレッドから呼ぶこと。 +unsafe fn write_view_png( + window: *mut AnyObject, + view: *mut AnyObject, + output_path: &str, +) -> Result<(), AppError> { + let bounds: NSRect = msg_send![view, bounds]; let bitmap = match create_bitmap_rep_for_bounds(bounds) { Ok(b) => b, Err(e) => { @@ -317,7 +356,7 @@ unsafe fn write_window_png(window: *mut AnyObject, output_path: &str) -> Result< } }; - let () = msg_send![content_view, cacheDisplayInRect: bounds toBitmapImageRep: bitmap]; + let () = msg_send![view, cacheDisplayInRect: bounds toBitmapImageRep: bitmap]; let properties: *mut AnyObject = msg_send![class!(NSDictionary), dictionary]; let data: *mut AnyObject = msg_send![ bitmap, diff --git a/src/settings_window/build.rs b/src/settings_window/build.rs index e1226af..49431d0 100644 --- a/src/settings_window/build.rs +++ b/src/settings_window/build.rs @@ -16,12 +16,12 @@ use crate::i18n::{self, Lang, Msg}; use crate::objc_helpers::template_image_from_png; use super::rows::{ - add_button_row, add_divider_row, add_field_row, add_language_row, add_login_item_row, - add_popup_row, add_slider_row, add_stepper_row, document_height, make_button, make_label, + add_button_row, add_divider_row, add_field_row, add_language_row, add_popup_row, + add_slider_row, add_stepper_row, add_toggle_row, document_height, make_button, make_label, make_pane_view, make_range_hint_popover, make_scroll_view, SETTINGS_PANE_HEIGHT, SETTINGS_STYLE_MASK, SETTINGS_TOTAL_ROW_COUNT, SETTINGS_WINDOW_WIDTH, }; -use super::{LocalizedControl, LocalizedKind, SettingsControls}; +use super::{InitialToggles, LocalizedControl, LocalizedKind, SettingsControls}; const GEAR_TEMPLATE_PNG: &[u8] = include_bytes!("../../assets/gear-template.png"); const BEER_TEMPLATE_PNG: &[u8] = include_bytes!("../../assets/beer-template.png"); @@ -230,7 +230,7 @@ unsafe fn add_pane_tab( pub unsafe fn build_settings_window( delegate: &AnyObject, lang: Lang, - start_at_login: bool, + toggles: InitialToggles, ) -> SettingsControls { let placeholder = default_display_settings(); let mut localized: Vec = Vec::new(); @@ -404,7 +404,8 @@ pub unsafe fn build_settings_window( placeholder.hud_image_max_height, &mut localized, ); - // 区切りから下(言語・ログイン項目)は下書き→保存のモデルに乗らず、操作した瞬間に保存・反映する + // 区切りから下(言語・ログイン項目・メニューバーアイコン表示)は下書き→保存のモデルに + // 乗らず、操作した瞬間に保存・反映する add_divider_row(document_view, row.next()); let language_popup = add_language_row( document_view, @@ -414,12 +415,26 @@ pub unsafe fn build_settings_window( placeholder.language, &mut localized, ); - let login_item_toggle = add_login_item_row( + let login_item_toggle = add_toggle_row( document_view, delegate, row.next(), lang, - start_at_login, + Msg::SettingsStartAtLogin, + ConfigKey::StartAtLogin, + sel!(toggleLoginItem:), + toggles.start_at_login, + &mut localized, + ); + let show_menu_bar_icon_toggle = add_toggle_row( + document_view, + delegate, + row.next(), + lang, + Msg::SettingsShowMenuBarIcon, + ConfigKey::ShowMenuBarIcon, + sel!(toggleMenuBarIcon:), + toggles.show_menu_bar_icon, &mut localized, ); // 行数が定数からずれると document_height と row_bottom_y の前提が崩れ、行がはみ出す @@ -473,6 +488,7 @@ pub unsafe fn build_settings_window( SettingsControls { window, + document_view, poll_interval_slider, poll_interval_value_label, hud_duration_slider, @@ -500,6 +516,7 @@ pub unsafe fn build_settings_window( hud_emoji_shadow: placeholder.hud_emoji.clone(), language_popup, login_item_toggle, + show_menu_bar_icon_toggle, draft: placeholder, preview_sample_index: 0, localized, diff --git a/src/settings_window/mod.rs b/src/settings_window/mod.rs index 10e807f..6c37b9b 100644 --- a/src/settings_window/mod.rs +++ b/src/settings_window/mod.rs @@ -16,7 +16,8 @@ pub use build::build_settings_window; pub(crate) use build::{TAB_INDEX_SETTINGS, TAB_INDEX_SUPPORT}; pub use sync::{ apply_language, apply_setting_change, handle_text_change, preview_settings, reset_settings, - save_settings, sync_controls_from_settings, sync_language_popup, sync_login_item_toggle, + save_settings, save_show_menu_bar_icon, sync_controls_from_settings, sync_language_popup, + sync_login_item_toggle, sync_menu_bar_icon_toggle, }; /// 言語切り替えで表示テキストを差し替える対象コントロール。 @@ -46,10 +47,21 @@ pub struct RangeHint { /// popover 本文(言語・接頭辞込みで解決済み)。 pub text: String, } + +/// `build_settings_window` へ渡す、下書きモデルの対象外なトグル行の初期表示値。 +/// bool を並べて渡すと引数の取り違えでもコンパイルが通ってしまうため、 +/// 意味のある名前を持つ struct にまとめる。 +pub struct InitialToggles { + pub start_at_login: bool, + pub show_menu_bar_icon: bool, +} /// 設定ウィンドウを構成するコントロールへのポインタ。`AppState` に保持し、 /// `openSettings:` で使い回す(ウィンドウは初回だけ生成する)。 pub struct SettingsControls { pub window: *mut AnyObject, + /// 行スタック全体(`NSScrollView` の documentView)。VRT が可視域の外の行まで撮るために持つ + /// (`render_settings_png` の `SettingsRows`)。 + pub document_view: *mut AnyObject, pub poll_interval_slider: *mut AnyObject, pub poll_interval_value_label: *mut AnyObject, pub hud_duration_slider: *mut AnyObject, @@ -98,12 +110,16 @@ pub struct SettingsControls { /// 設定ファイルへ保存し、`login_item::enable`/`disable` で plist をそれに合わせる /// (`toggleLoginItem:`)。 pub login_item_toggle: *mut AnyObject, + /// メニューバーアイコン表示のトグル。下書き→保存のモデルには乗らず、チェックした瞬間に + /// 設定ファイルへ保存し、`menu::apply_menu_bar_icon_visibility` で status item をそれに + /// 合わせる(`toggleMenuBarIcon:`)。 + pub show_menu_bar_icon_toggle: *mut AnyObject, /// ウィンドウ内で編集中の下書き。「保存」(`saveSettings:`)を押すまで設定ファイルには /// 反映しない。`settingChanged:` はこの下書きだけを更新する。 /// - /// `language` はこの下書きの対象外だが、フィールド自体は `DisplaySettings` の一部として - /// 常に持つ。「保存」時に古い言語で上書きしないよう、言語が変わるたびに - /// `apply_settings_now`/`reset_settings` の側でここも一緒に同期すること。 + /// `language`・`show_menu_bar_icon` はこの下書きの対象外だが、フィールド自体は + /// `DisplaySettings` の一部として常に持つ。「保存」時に古い値で上書きしないよう、 + /// 値が変わるたびに `apply_settings_now`/`reset_settings` の側でここも一緒に同期すること。 pub draft: DisplaySettings, /// 「お試し表示」を押すたびに進める、次に表示するサンプルの番号(`PREVIEW_SAMPLES` を巡回)。 pub preview_sample_index: usize, @@ -118,6 +134,7 @@ impl Default for SettingsControls { hud_emoji_shadow: draft.hud_emoji.clone(), draft, window: ptr::null_mut(), + document_view: ptr::null_mut(), poll_interval_slider: ptr::null_mut(), poll_interval_value_label: ptr::null_mut(), hud_duration_slider: ptr::null_mut(), @@ -144,6 +161,7 @@ impl Default for SettingsControls { range_hint_close_timer: ptr::null_mut(), language_popup: ptr::null_mut(), login_item_toggle: ptr::null_mut(), + show_menu_bar_icon_toggle: ptr::null_mut(), preview_sample_index: 0, localized: Vec::new(), } @@ -165,6 +183,7 @@ pub fn config_key_to_tag(key: ConfigKey) -> isize { ConfigKey::Language => 10, ConfigKey::HudBackgroundOpacity => 11, ConfigKey::StartAtLogin => 12, + ConfigKey::ShowMenuBarIcon => 13, } } @@ -183,6 +202,7 @@ pub fn tag_to_config_key(tag: isize) -> Option { 10 => Some(ConfigKey::Language), 11 => Some(ConfigKey::HudBackgroundOpacity), 12 => Some(ConfigKey::StartAtLogin), + 13 => Some(ConfigKey::ShowMenuBarIcon), _ => None, } } @@ -202,7 +222,7 @@ mod tests { #[test] fn tag_to_config_key_rejects_unknown_tags() { assert_eq!(tag_to_config_key(-1), None); - assert_eq!(tag_to_config_key(13), None); + assert_eq!(tag_to_config_key(14), None); assert_eq!(tag_to_config_key(9999), None); } } diff --git a/src/settings_window/rows.rs b/src/settings_window/rows.rs index 764ecbb..4900a57 100644 --- a/src/settings_window/rows.rs +++ b/src/settings_window/rows.rs @@ -18,9 +18,9 @@ pub(super) const SETTINGS_STYLE_MASK: usize = 1 | 2; pub(super) const SETTINGS_WINDOW_WIDTH: f64 = 520.0; const SETTINGS_ROW_HEIGHT: f64 = 34.0; const SETTINGS_ROW_COUNT: usize = 11; -// HUD の見た目を決める上記 11 行とは別に、区切り行・言語・ログイン項目の 3 行を末尾に置く。 -// 各行の番号は build_settings_window が出現順に払い出す。 -pub(super) const SETTINGS_TOTAL_ROW_COUNT: usize = SETTINGS_ROW_COUNT + 3; +// HUD の見た目を決める上記 11 行とは別に、区切り行・言語・自動起動・メニューバーアイコン +// 表示の 4 行を末尾に置く。各行の番号は build_settings_window が出現順に払い出す。 +pub(super) const SETTINGS_TOTAL_ROW_COUNT: usize = SETTINGS_ROW_COUNT + 4; const SETTINGS_TOP_MARGIN: f64 = 20.0; const SETTINGS_BOTTOM_MARGIN: f64 = 20.0; // ウィンドウ下部、行の並びとは別に「デフォルトに戻す」「お試し表示」「保存」ボタンを置くための領域 @@ -879,13 +879,19 @@ pub(super) unsafe fn add_divider_row(document_view: *mut AnyObject, index: usize let () = msg_send![document_view, addSubview: divider]; } -/// ログイン時の自動起動の行。`enabled` は表示専用の初期値で、 -/// 実際の値はウィンドウを開くたびに `sync_login_item_toggle` が上書きする。 -pub(super) unsafe fn add_login_item_row( +/// NSSwitch トグル行の組み立て。下書きモデルの対象外で、操作した瞬間に専用アクションへ +/// 保存・反映が飛ぶ設定行(自動起動・メニューバーアイコン表示)はどちらもこの形。 +/// `enabled` は表示専用の初期値で、実際の値はウィンドウを開くたびに呼び出し側の +/// `sync_login_item_toggle`/`sync_menu_bar_icon_toggle` が上書きする。 +#[allow(clippy::too_many_arguments)] +pub(super) unsafe fn add_toggle_row( document_view: *mut AnyObject, delegate: &AnyObject, index: usize, lang: Lang, + label_msg: Msg, + key: ConfigKey, + action: Sel, enabled: bool, localized: &mut Vec, ) -> *mut AnyObject { @@ -896,7 +902,7 @@ pub(super) unsafe fn add_login_item_row( SETTINGS_LABEL_HEIGHT, row_bottom, ); - let label = make_label(i18n::text(lang, Msg::SettingsStartAtLogin), label_rect); + let label = make_label(i18n::text(lang, label_msg), label_rect); let toggle: *mut AnyObject = msg_send![class!(NSSwitch), alloc]; let toggle: *mut AnyObject = msg_send![toggle, init]; let () = msg_send![toggle, setControlSize: NS_CONTROL_SIZE_SMALL]; @@ -910,24 +916,24 @@ pub(super) unsafe fn add_login_item_row( row_bottom, ); let () = msg_send![toggle, setFrame: toggle_rect]; - let () = msg_send![toggle, setState: login_item_control_state(enabled)]; - let () = msg_send![toggle, setTag: config_key_to_tag(ConfigKey::StartAtLogin)]; + let () = msg_send![toggle, setState: toggle_control_state(enabled)]; + let () = msg_send![toggle, setTag: config_key_to_tag(key)]; let () = msg_send![toggle, setTarget: delegate]; - let () = msg_send![toggle, setAction: sel!(toggleLoginItem:)]; + let () = msg_send![toggle, setAction: action]; let () = msg_send![document_view, addSubview: label]; let () = msg_send![document_view, addSubview: toggle]; localized.push(LocalizedControl { control: label, - msg: Msg::SettingsStartAtLogin, + msg: label_msg, kind: LocalizedKind::StringValue, }); toggle } -pub(super) fn login_item_control_state(enabled: bool) -> isize { +fn toggle_control_state(enabled: bool) -> isize { if enabled { NS_CONTROL_STATE_VALUE_ON } else { @@ -935,6 +941,18 @@ pub(super) fn login_item_control_state(enabled: bool) -> isize { } } +/// トグル行のスイッチの表示を合わせる。設定ウィンドウを一度も開いていないとコントロールは +/// null で、ファイル監視の再読み込みはその状態でもここへ来る(`select_language_item` と同じ)。 +/// +/// # Safety +/// AppKit のメインスレッドから呼ぶこと。 +pub(super) unsafe fn set_toggle_state(toggle: *mut AnyObject, enabled: bool) { + if toggle.is_null() { + return; + } + let () = msg_send![toggle, setState: toggle_control_state(enabled)]; +} + /// タブ 1 枚分のビュー。余白クリックでテキストフィールドの編集を確定させる動きを /// タブごとに保つため、どのペインも `background_view_class` で作る。 pub(super) unsafe fn make_pane_view(height: f64) -> *mut AnyObject { @@ -1088,7 +1106,7 @@ mod tests { // 下の行を見せる(ウィンドウは伸ばさない)。 #[test] fn document_height_exceeds_scroll_height() { - assert_eq!(document_height(), 496.0); + assert_eq!(document_height(), 530.0); assert!(document_height() > SETTINGS_SCROLL_HEIGHT); } @@ -1096,7 +1114,7 @@ mod tests { // 起きるとここで落ちる #[test] fn row_bottom_y_pins_the_first_row() { - assert_eq!(row_bottom_y(0), 442.0); + assert_eq!(row_bottom_y(0), 476.0); } // 最終行はちょうど documentView の下端に載る(負なら下へはみ出して見えなくなる) diff --git a/src/settings_window/sync.rs b/src/settings_window/sync.rs index 4098cbc..1c89ea1 100644 --- a/src/settings_window/sync.rs +++ b/src/settings_window/sync.rs @@ -18,8 +18,8 @@ use crate::png::create_preview_sample_image; use super::input_filter::filter_digits; use super::range_hint::range_hint_text; use super::rows::{ - login_item_control_state, resize_help_popover_to_fit_label, select_language_item, - select_popup_item, set_string_value, + resize_help_popover_to_fit_label, select_language_item, select_popup_item, set_string_value, + set_toggle_state, }; use super::tooltip::tooltip_text; use super::{tag_to_config_key, LocalizedKind, RangeHint, SettingsControls}; @@ -151,6 +151,7 @@ pub unsafe fn sync_controls_from_settings( settings.hud_background_color.as_str(), ); select_language_item(controls.language_popup, settings.language); + sync_menu_bar_icon_toggle(controls, settings.show_menu_bar_icon); set_emoji_field(controls, &settings.hud_emoji); } @@ -162,8 +163,17 @@ pub unsafe fn sync_controls_from_settings( /// # Safety /// AppKit のメインスレッドから呼ぶこと。 pub unsafe fn sync_login_item_toggle(controls: &SettingsControls, start_at_login: bool) { - let state = login_item_control_state(start_at_login); - let () = msg_send![controls.login_item_toggle, setState: state]; + set_toggle_state(controls.login_item_toggle, start_at_login); +} + +/// メニューバーアイコン表示トグルの表示を `show_menu_bar_icon` に合わせる。下書きモデルの +/// 対象外のため、`sync_controls_from_settings`・ファイル監視の再読み込み・ +/// `toggleMenuBarIcon:` が保存に失敗したときの巻き戻しの各経路から呼ぶ。 +/// +/// # Safety +/// AppKit のメインスレッドから呼ぶこと。 +pub unsafe fn sync_menu_bar_icon_toggle(controls: &SettingsControls, show_menu_bar_icon: bool) { + set_toggle_state(controls.show_menu_bar_icon_toggle, show_menu_bar_icon); } /// 言語ポップアップの選択を、いま効いている設定値に合わせる。 @@ -509,6 +519,37 @@ unsafe fn save_language_setting( crate::app::display_settings_from_file(&path).map_err(|error| error.to_string()) } +/// メニューバーアイコン表示の設定を保存し、保存後のファイル内容から `DisplaySettings` を +/// 組み直す。`show_menu_bar_icon` だけ差し替えると、外部エディタでの変更を取り込まないまま +/// `config_mtime` だけ進み、ファイル監視がその変更を二度と拾わなくなるため、 +/// `display_settings_from_file` でファイルから組み直す。`toggleMenuBarIcon:` から呼ぶ。 +/// +/// # Safety +/// - `APP_STATE` をロックしないこと(呼び出し側が既にロックを保持している)。 +/// - AppKit のメインスレッドから呼ぶこと。 +pub unsafe fn save_show_menu_bar_icon( + state: &mut AppState, + value: bool, +) -> Result { + let Some(path) = state.config_path.clone() else { + return Err( + "config path is not resolved; cannot save show_menu_bar_icon setting".to_string(), + ); + }; + let (mut config, _) = load_config_file(&path).map_err(|error| error.to_string())?; + let start_at_login = start_at_login_from_config(&config); + let raw = if value { "true" } else { "false" }; + set_config_value(&mut config, ConfigKey::ShowMenuBarIcon, raw) + .map_err(|error| error.to_string())?; + save_config_file(&path, &config).map_err(|error| error.to_string())?; + state.config_mtime = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()); + apply_start_at_login_if_changed(state, start_at_login); + + crate::app::display_settings_from_file(&path).map_err(|error| error.to_string()) +} + /// 言語切り替えで、設定ウィンドウ内の静的なラベル・タイトル・ツールチップをすべて差し替える。 /// /// `APP_STATE` のロックを保持したまま呼んでよい: `NSTextField` ラベル・`NSButton`/`NSWindow` @@ -558,15 +599,17 @@ pub unsafe fn apply_language(controls: &SettingsControls, lang: Lang) { } /// 下書きを既定値に戻し、コントロール表示を同期する。保存も HUD への適用もしない。 -/// 言語は下書きモデルの対象外のため、既定値へは戻さず現在値を保つ。 +/// 言語・メニューバーアイコン表示は下書きモデルの対象外のため、既定値へは戻さず現在値を保つ。 /// /// # Safety /// - `APP_STATE` をロックしないこと(呼び出し側が既にロックを保持している)。 /// - AppKit のメインスレッドから呼ぶこと。 pub unsafe fn reset_settings(state: &mut AppState) { let language = state.settings_controls.draft.language; + let show_menu_bar_icon = state.settings_controls.draft.show_menu_bar_icon; state.settings_controls.draft = default_display_settings(); state.settings_controls.draft.language = language; + state.settings_controls.draft.show_menu_bar_icon = show_menu_bar_icon; let draft = state.settings_controls.draft.clone(); sync_controls_from_settings(&mut state.settings_controls, &draft); } @@ -703,6 +746,7 @@ fn control_for_key(controls: &SettingsControls, key: ConfigKey) -> *mut AnyObjec ConfigKey::HudBackgroundOpacity => controls.hud_background_opacity_slider, ConfigKey::HudEmoji => controls.hud_emoji_field, ConfigKey::Language => controls.language_popup, + ConfigKey::ShowMenuBarIcon => controls.show_menu_bar_icon_toggle, ConfigKey::StartAtLogin => controls.login_item_toggle, } } @@ -736,6 +780,8 @@ unsafe fn raw_value_for_key(key: ConfigKey, control: *mut AnyObject) -> Option None, + // メニューバーアイコン表示トグルも専用の toggleMenuBarIcon: を持ち、同じ理由で対象外 + ConfigKey::ShowMenuBarIcon => None, } } @@ -799,6 +845,9 @@ unsafe fn resync_controls_after_apply( // 自動起動も下書きモデルの対象外(専用の toggleLoginItem: が設定ファイルへ保存し、 // plist はその派生物として合わせる)。 ConfigKey::StartAtLogin => {} + // メニューバーアイコン表示も下書きモデルの対象外(専用の toggleMenuBarIcon: が + // 設定ファイルへ保存し、status item の表示はその派生物として合わせる)。 + ConfigKey::ShowMenuBarIcon => {} } } @@ -818,4 +867,15 @@ mod tests { assert!(class!(NSTextView).responds_to(sel!(string))); assert!(class!(NSString).responds_to(sel!(length))); } + + /// 設定ウィンドウを一度も開いていないとトグルのコントロールは null。設定ファイルの + /// 手編集はその状態でもトグルの同期まで来るため、null へ `setState:` を送らないこと。 + #[test] + fn syncing_toggles_without_a_settings_window_does_not_message_nil() { + let controls = super::SettingsControls::default(); + unsafe { + super::sync_login_item_toggle(&controls, true); + super::sync_menu_bar_icon_toggle(&controls, false); + } + } } diff --git a/tests/visual/baseline/settings_rows_en.png b/tests/visual/baseline/settings_rows_en.png new file mode 100644 index 0000000..71bfe95 Binary files /dev/null and b/tests/visual/baseline/settings_rows_en.png differ diff --git a/tests/visual/baseline/settings_rows_ja.png b/tests/visual/baseline/settings_rows_ja.png new file mode 100644 index 0000000..b5ad7ec Binary files /dev/null and b/tests/visual/baseline/settings_rows_ja.png differ