From 897d79cf5b4fd41a128cf7a1fec08ee7e89e867f Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 14 Apr 2026 01:11:10 +0900 Subject: [PATCH 1/4] Implementation of Layer Display --- public/layer.html | 101 ++++ public/layer_ctrl.html | 145 ++++++ settings.html | 12 + src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 5 + src-tauri/capabilities/layer-capability.json | 19 + .../capabilities/settings-capability.json | 18 + src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/main.rs | 435 +++++++++++++++++- src-tauri/tauri.conf.json | 3 +- src/App.tsx | 307 +++++++----- src/SettingsApp.tsx | 376 +++++++++++++++ src/components/Settings.tsx | 163 ------- src/index.css | 116 +++++ src/settings-main.tsx | 11 + src/settings-page.css | 383 +++++++++++++++ src/types.ts | 8 +- vite.config.ts | 10 + 18 files changed, 1825 insertions(+), 291 deletions(-) create mode 100644 public/layer.html create mode 100644 public/layer_ctrl.html create mode 100644 settings.html create mode 100644 src-tauri/capabilities/layer-capability.json create mode 100644 src-tauri/capabilities/settings-capability.json create mode 100644 src/SettingsApp.tsx delete mode 100644 src/components/Settings.tsx create mode 100644 src/settings-main.tsx create mode 100644 src/settings-page.css diff --git a/public/layer.html b/public/layer.html new file mode 100644 index 0000000..a7d595c --- /dev/null +++ b/public/layer.html @@ -0,0 +1,101 @@ + + + + + + Lightning Timer Overlay + + + +
00:00
+ + + + diff --git a/public/layer_ctrl.html b/public/layer_ctrl.html new file mode 100644 index 0000000..235b1e5 --- /dev/null +++ b/public/layer_ctrl.html @@ -0,0 +1,145 @@ + + + + + + Layer Controls + + + +
+
+ ⋮⋮ +
+ +
+ + + + diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..916802e --- /dev/null +++ b/settings.html @@ -0,0 +1,12 @@ + + + + + + Lightning Timer Settings + + +
+ + + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2de9500..49a83c6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "2.0", features = ["tray-icon", "devtools"] } +tauri = { version = "2.0", features = ["tray-icon", "devtools", "macos-private-api"] } tauri-plugin-store = "2.0" dirs = "5.0" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index de47952..0d509ac 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,11 @@ "core:window:allow-set-position", "core:window:allow-current-monitor", "core:window:allow-outer-position", + "core:event:default", + "core:event:allow-emit", + "core:event:allow-emit-to", + "core:event:allow-listen", + "core:event:allow-unlisten", "store:default" ] } diff --git a/src-tauri/capabilities/layer-capability.json b/src-tauri/capabilities/layer-capability.json new file mode 100644 index 0000000..001c7f2 --- /dev/null +++ b/src-tauri/capabilities/layer-capability.json @@ -0,0 +1,19 @@ +{ + "identifier": "layer-capability", + "description": "Layer overlay window capabilities", + "windows": ["layer", "layer_ctrl"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-position", + "core:window:allow-outer-position", + "core:window:allow-start-dragging", + "core:event:default", + "core:event:allow-emit", + "core:event:allow-emit-to", + "core:event:allow-listen", + "core:event:allow-unlisten" + ] +} diff --git a/src-tauri/capabilities/settings-capability.json b/src-tauri/capabilities/settings-capability.json new file mode 100644 index 0000000..082e3eb --- /dev/null +++ b/src-tauri/capabilities/settings-capability.json @@ -0,0 +1,18 @@ +{ + "identifier": "settings-capability", + "description": "Settings window capabilities", + "windows": ["settings"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:event:default", + "core:event:allow-emit", + "core:event:allow-emit-to", + "core:event:allow-listen", + "core:event:allow-unlisten", + "store:default" + ] +} diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index c4a05c4..6816599 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-capability":{"identifier":"main-capability","description":"Main window capabilities","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-set-always-on-top","core:window:allow-create","core:window:allow-close","core:window:allow-center","core:window:allow-set-size","core:window:allow-set-resizable","core:window:allow-set-decorations","core:window:allow-set-always-on-top","core:window:allow-set-fullscreen","core:window:allow-set-max-size","core:window:allow-set-min-size","core:window:allow-set-focus","core:window:allow-set-position","core:window:allow-current-monitor","core:window:allow-outer-position","store:default"]},"timeup-capability":{"identifier":"timeup-capability","description":"Time Up window capabilities","local":true,"windows":["timeup"],"permissions":["core:default","core:window:allow-close"]}} \ No newline at end of file +{"layer-capability":{"identifier":"layer-capability","description":"Layer overlay window capabilities","local":true,"windows":["layer","layer_ctrl"],"permissions":["core:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-position","core:window:allow-outer-position","core:window:allow-start-dragging","core:event:default","core:event:allow-emit","core:event:allow-emit-to","core:event:allow-listen","core:event:allow-unlisten"]},"main-capability":{"identifier":"main-capability","description":"Main window capabilities","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-set-always-on-top","core:window:allow-create","core:window:allow-close","core:window:allow-center","core:window:allow-set-size","core:window:allow-set-resizable","core:window:allow-set-decorations","core:window:allow-set-always-on-top","core:window:allow-set-fullscreen","core:window:allow-set-max-size","core:window:allow-set-min-size","core:window:allow-set-focus","core:window:allow-set-position","core:window:allow-current-monitor","core:window:allow-outer-position","core:event:default","core:event:allow-emit","core:event:allow-emit-to","core:event:allow-listen","core:event:allow-unlisten","store:default"]},"settings-capability":{"identifier":"settings-capability","description":"Settings window capabilities","local":true,"windows":["settings"],"permissions":["core:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","core:event:default","core:event:allow-emit","core:event:allow-emit-to","core:event:allow-listen","core:event:allow-unlisten","store:default"]},"timeup-capability":{"identifier":"timeup-capability","description":"Time Up window capabilities","local":true,"windows":["timeup"],"permissions":["core:default","core:window:allow-close"]}} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c64ffab..024e31e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::fs; #[cfg(not(debug_assertions))] use std::net::{SocketAddr, TcpListener}; -use tauri::{AppHandle, Manager, WindowEvent}; +use tauri::{AppHandle, Emitter, Manager, WindowEvent}; use tauri_plugin_store::Builder as StoreBuilder; #[cfg(target_os = "macos")] @@ -356,6 +356,396 @@ async fn show_timeup_window(app: AppHandle) -> Result<(), String> { Ok(()) } +/// macOSで透過オーバーレイウィンドウを他アプリのフルスクリーン Space 上にも表示するように設定する。 +/// +/// - `panel_nonactivating = true` : NSPanel へクラススワップして nonactivating にする +/// (layer 本体。クリックスルーで入力不要なので副作用なし。フルスクリーンアプリ上に出る) +/// - `panel_nonactivating = false` : NSWindow のまま level と collectionBehavior のみ設定 +/// (layer_ctrl。NSPanel 化すると webview にクリックイベントが届かなくなるため。 +/// 他アプリのフルスクリーン Space には出ないが、クリック可能) +#[cfg(target_os = "macos")] +fn apply_macos_overlay_behavior(window: &tauri::WebviewWindow, panel_nonactivating: bool) { + use objc2::msg_send; + use objc2::runtime::AnyClass; + + let label = window.label().to_string(); + let _ = window.with_webview(move |webview| unsafe { + let ns_window_ptr = webview.ns_window() as *mut objc2::runtime::AnyObject; + if ns_window_ptr.is_null() { + println!("DEBUG[{}]: ns_window pointer is null", label); + return; + } + + if panel_nonactivating { + // NSWindow -> NSPanel へクラススワップ + if let Some(panel_class) = AnyClass::get(c"NSPanel") { + extern "C" { + fn object_setClass( + obj: *mut objc2::runtime::AnyObject, + cls: *const objc2::runtime::AnyClass, + ) -> *const objc2::runtime::AnyClass; + } + object_setClass(ns_window_ptr, panel_class); + println!("DEBUG[{}]: NSWindow -> NSPanel class swap done", label); + } + + // NonactivatingPanel スタイルマスク追加 + let current_mask: usize = msg_send![ns_window_ptr, styleMask]; + let new_mask = current_mask | (1usize << 7); + let _: () = msg_send![ns_window_ptr, setStyleMask: new_mask]; + + let _: () = msg_send![ns_window_ptr, setFloatingPanel: true]; + let _: () = msg_send![ns_window_ptr, setHidesOnDeactivate: false]; + } + + // CanJoinAllSpaces | Stationary | FullScreenAuxiliary + let behavior: usize = (1 << 0) | (1 << 4) | (1 << 8); + let _: () = msg_send![ns_window_ptr, setCollectionBehavior: behavior]; + + let level: isize = 1000; + let _: () = msg_send![ns_window_ptr, setLevel: level]; + + let applied_level: isize = msg_send![ns_window_ptr, level]; + let applied_mask: usize = msg_send![ns_window_ptr, styleMask]; + let applied_behavior: usize = msg_send![ns_window_ptr, collectionBehavior]; + println!( + "DEBUG[{}]: overlay applied (panel_nonactivating={}, level={}, styleMask=0x{:x}, collectionBehavior=0x{:x})", + label, panel_nonactivating, applied_level, applied_mask, applied_behavior + ); + }); +} + +/// レイヤーディスプレイウィンドウの既定位置とサイズ(論理ピクセル・グローバル座標)を計算する。 +/// 複数モニター環境では main ウィンドウが乗っているモニターを優先する +fn layer_default_geometry(app: &AppHandle) -> (f64, f64, f64, f64) { + let layer_width = 320.0; + let layer_height = 120.0; + if let Some(main) = app.get_webview_window("main") { + let monitor = main + .current_monitor() + .ok() + .flatten() + .or_else(|| main.primary_monitor().ok().flatten()); + if let Some(monitor) = monitor { + let pos = monitor.position(); + let size = monitor.size(); + let scale = monitor.scale_factor(); + let origin_x = pos.x as f64 / scale; + let origin_y = pos.y as f64 / scale; + let screen_w = size.width as f64 / scale; + let x = origin_x + screen_w - layer_width - 40.0; + let y = origin_y + 40.0; + return (x, y, layer_width, layer_height); + } + } + (1200.0, 40.0, layer_width, layer_height) +} + +/// 操作ハンドルの位置を元にディスプレイウィンドウの位置を同期する +fn sync_layer_to_ctrl(app: &AppHandle) { + let ctrl = match app.get_webview_window("layer_ctrl") { + Some(w) => w, + None => return, + }; + let layer = match app.get_webview_window("layer") { + Some(w) => w, + None => return, + }; + let ctrl_pos = match ctrl.outer_position() { + Ok(p) => p, + Err(_) => return, + }; + let ctrl_size = match ctrl.outer_size() { + Ok(s) => s, + Err(_) => return, + }; + let layer_size = match layer.outer_size() { + Ok(s) => s, + Err(_) => return, + }; + // 操作ハンドルの直上にディスプレイを中心合わせで配置 + let gap_physical: i32 = 8; + let new_x = ctrl_pos.x + (ctrl_size.width as i32) / 2 - (layer_size.width as i32) / 2; + let new_y = ctrl_pos.y - (layer_size.height as i32) - gap_physical; + let _ = layer.set_position(tauri::Position::Physical(tauri::PhysicalPosition { + x: new_x, + y: new_y, + })); +} + +#[tauri::command] +async fn show_layer_window(app: AppHandle) -> Result<(), String> { + println!("DEBUG: show_layer_window called"); + + let (default_x, default_y, layer_w, layer_h) = layer_default_geometry(&app); + let ctrl_w: f64 = 80.0; + let ctrl_h: f64 = 28.0; + let ctrl_gap: f64 = 8.0; + let ctrl_x = default_x + (layer_w - ctrl_w) / 2.0; + let ctrl_y = default_y + layer_h + ctrl_gap; + + // ディスプレイ用(透過・クリックスルー) + let layer_window = if let Some(w) = app.get_webview_window("layer") { + w.show().map_err(|e| format!("Failed to show layer: {}", e))?; + w + } else { + tauri::WebviewWindowBuilder::new( + &app, + "layer", + tauri::WebviewUrl::App("layer.html".into()), + ) + .title("Lightning Timer Overlay") + .inner_size(layer_w, layer_h) + .position(default_x, default_y) + .resizable(false) + .decorations(false) + .transparent(true) + .shadow(false) + .always_on_top(true) + .skip_taskbar(true) + .focused(false) + .visible(true) + .build() + .map_err(|e| format!("Failed to create layer window: {}", e))? + }; + + // クリックスルー有効化 + if let Err(e) = layer_window.set_ignore_cursor_events(true) { + println!("DEBUG: Failed to set ignore_cursor_events: {}", e); + } + + #[cfg(target_os = "macos")] + apply_macos_overlay_behavior(&layer_window, true); + + // 操作ハンドル(非クリックスルー・ドラッグ + 閉じる) + let ctrl_window = if let Some(w) = app.get_webview_window("layer_ctrl") { + w.show().map_err(|e| format!("Failed to show layer_ctrl: {}", e))?; + w + } else { + tauri::WebviewWindowBuilder::new( + &app, + "layer_ctrl", + tauri::WebviewUrl::App("layer_ctrl.html".into()), + ) + .title("Lightning Timer Controls") + .inner_size(ctrl_w, ctrl_h) + .position(ctrl_x, ctrl_y) + .resizable(false) + .decorations(false) + .transparent(true) + .shadow(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(true) + .build() + .map_err(|e| format!("Failed to create layer_ctrl window: {}", e))? + }; + + #[cfg(target_os = "macos")] + apply_macos_overlay_behavior(&ctrl_window, false); + + // 初期位置同期 + sync_layer_to_ctrl(&app); + + Ok(()) +} + +/// 16進カラー文字列のサニタイズ。妥当でなければ既定値を返す +fn sanitize_hex_color(color: &str) -> String { + let trimmed = color.trim(); + if trimmed.starts_with('#') + && (trimmed.len() == 4 || trimmed.len() == 7 || trimmed.len() == 9) + && trimmed[1..].chars().all(|c| c.is_ascii_hexdigit()) + { + trimmed.to_string() + } else { + "#00ff66".to_string() + } +} + +#[tauri::command] +async fn update_layer_timer( + app: AppHandle, + minutes: u32, + seconds: u32, + show_time_up: bool, +) -> Result<(), String> { + if let Some(layer) = app.get_webview_window("layer") { + // min/max で値を妥当な範囲に丸める + let m = minutes.min(99); + let s = seconds.min(99); + let content = if show_time_up { + "TIME UP".to_string() + } else { + format!("{:02}:{:02}", m, s) + }; + let class_op = if show_time_up { "add" } else { "remove" }; + let script = format!( + "(function(){{var el=document.getElementById('time');if(!el)return;el.textContent='{}';el.classList.{}('timeup');}})();", + content, class_op + ); + if let Err(e) = layer.eval(&script) { + println!("DEBUG: Failed to eval layer timer: {}", e); + return Err(format!("Failed to update layer timer: {}", e)); + } + } + Ok(()) +} + +#[tauri::command] +async fn update_layer_style( + app: AppHandle, + color: String, + shadow: String, + font_size: f64, +) -> Result<(), String> { + let safe_color = sanitize_hex_color(&color); + let shadow_value = if shadow == "light" { + "0 0 8px rgba(255,255,255,0.95), 0 0 16px rgba(255,255,255,0.8), 0 2px 4px rgba(255,255,255,1)" + } else { + "0 0 8px rgba(0,0,0,0.9), 0 0 16px rgba(0,0,0,0.7), 0 2px 4px rgba(0,0,0,1)" + }; + let safe_font_size = font_size.clamp(1.0, 20.0); + + if let Some(layer) = app.get_webview_window("layer") { + let script = format!( + "(function(){{var r=document.documentElement;r.style.setProperty('--layer-color','{}');r.style.setProperty('--layer-shadow','{}');r.style.setProperty('--layer-font-size','{}rem');console.log('[layer] style set via eval',r.style.getPropertyValue('--layer-color'),r.style.getPropertyValue('--layer-font-size'));}})();", + safe_color, shadow_value, safe_font_size + ); + if let Err(e) = layer.eval(&script) { + println!("DEBUG: Failed to eval layer style: {}", e); + return Err(format!("Failed to update layer style: {}", e)); + } + println!( + "DEBUG: Layer style updated color={} shadow={} fontSize={}rem", + safe_color, shadow, safe_font_size + ); + } + Ok(()) +} + +#[tauri::command] +async fn hide_layer_window(app: AppHandle) -> Result<(), String> { + println!("DEBUG: hide_layer_window called"); + if let Some(w) = app.get_webview_window("layer") { + match w.hide() { + Ok(()) => println!("DEBUG: layer window hidden"), + Err(e) => println!("DEBUG: Failed to hide layer window: {}", e), + } + } else { + println!("DEBUG: layer window not found"); + } + if let Some(w) = app.get_webview_window("layer_ctrl") { + match w.hide() { + Ok(()) => println!("DEBUG: layer_ctrl window hidden"), + Err(e) => println!("DEBUG: Failed to hide layer_ctrl window: {}", e), + } + } else { + println!("DEBUG: layer_ctrl window not found"); + } + Ok(()) +} + +/// layer_ctrl の × ボタンから呼ばれる。 +/// 1 度の invoke で hide + main 通知までまとめて実行する。 +/// (JS 側で invoke → emitTo の 2 段にすると layer_ctrl 自体が消えてから emit するため +/// イベントが失われていた) +#[tauri::command] +async fn exit_layer_mode(app: AppHandle) -> Result<(), String> { + println!("DEBUG: exit_layer_mode called"); + // 先に main へ通知してから hide (順序が逆だと layer_ctrl のコンテキストが消える可能性がある) + if let Err(e) = app.emit_to( + tauri::EventTarget::webview_window("main"), + "layer-exit-requested", + (), + ) { + println!("DEBUG: Failed to emit layer-exit-requested: {}", e); + } + if let Some(w) = app.get_webview_window("layer") { + if let Err(e) = w.hide() { + println!("DEBUG: Failed to hide layer: {}", e); + } + } + if let Some(w) = app.get_webview_window("layer_ctrl") { + if let Err(e) = w.hide() { + println!("DEBUG: Failed to hide layer_ctrl: {}", e); + } + } + Ok(()) +} + +/// main ウィンドウが乗っているモニターの中心座標 (論理ピクセル) を計算する +fn center_on_main_monitor(app: &AppHandle, width: f64, height: f64) -> (f64, f64) { + if let Some(main) = app.get_webview_window("main") { + let monitor = main + .current_monitor() + .ok() + .flatten() + .or_else(|| main.primary_monitor().ok().flatten()); + if let Some(monitor) = monitor { + let pos = monitor.position(); + let size = monitor.size(); + let scale = monitor.scale_factor(); + let origin_x = pos.x as f64 / scale; + let origin_y = pos.y as f64 / scale; + let screen_w = size.width as f64 / scale; + let screen_h = size.height as f64 / scale; + let x = origin_x + (screen_w - width) / 2.0; + let y = origin_y + (screen_h - height) / 2.0; + return (x, y); + } + } + (200.0, 200.0) +} + +#[tauri::command] +async fn show_settings_window(app: AppHandle) -> Result<(), String> { + println!("DEBUG: show_settings_window called"); + let width = 540.0; + let height = 640.0; + + if let Some(w) = app.get_webview_window("settings") { + // 既存ウィンドウは現在のモニターに移動してから表示 + let (x, y) = center_on_main_monitor(&app, width, height); + let _ = w.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + return Ok(()); + } + + let (x, y) = center_on_main_monitor(&app, width, height); + + let window = tauri::WebviewWindowBuilder::new( + &app, + "settings", + tauri::WebviewUrl::App("settings.html".into()), + ) + .title("Lightning Timer Settings") + .inner_size(width, height) + .min_inner_size(420.0, 480.0) + .position(x, y) + .resizable(true) + .decorations(true) + .always_on_top(false) + .skip_taskbar(false) + .visible(true) + .build() + .map_err(|e| format!("Failed to create settings window: {}", e))?; + + let _ = window.set_focus(); + Ok(()) +} + +#[tauri::command] +async fn hide_settings_window(app: AppHandle) -> Result<(), String> { + println!("DEBUG: hide_settings_window called"); + if let Some(w) = app.get_webview_window("settings") { + let _ = w.hide(); + } + Ok(()) +} + #[tauri::command] async fn hide_timeup_window(app: AppHandle) -> Result<(), String> { println!("DEBUG: hide_timeup_window command called"); @@ -391,22 +781,41 @@ fn main() { } Ok(()) }) - .invoke_handler(tauri::generate_handler![open_devtools, save_timer_state_on_exit, exit_app, start_drag, save_window_position, set_window_size, set_window_resizable, focus_window, get_available_port, show_timeup_window, hide_timeup_window]) + .invoke_handler(tauri::generate_handler![open_devtools, save_timer_state_on_exit, exit_app, start_drag, save_window_position, set_window_size, set_window_resizable, focus_window, get_available_port, show_timeup_window, hide_timeup_window, show_layer_window, hide_layer_window, update_layer_style, update_layer_timer, exit_layer_mode, show_settings_window, hide_settings_window]) .on_window_event(|window, event| { - if let WindowEvent::CloseRequested { .. } = event { - // タイマー状態保存を促す - if let Some(main_window) = window.app_handle().get_webview_window("main") { - let _ = main_window.eval("if (window.__TAURI__) { window.__TAURI__.core.invoke('save_timer_state_on_exit'); }"); - } + match event { + WindowEvent::CloseRequested { api, .. } => { + // settings ウィンドウは破棄せず非表示にして使い回す + if window.label() == "settings" { + api.prevent_close(); + let _ = window.hide(); + return; + } + // main ウィンドウが閉じられた場合のみアプリ全体を終了 + if window.label() != "main" { + return; + } + // タイマー状態保存を促す + if let Some(main_window) = window.app_handle().get_webview_window("main") { + let _ = main_window.eval("if (window.__TAURI__) { window.__TAURI__.core.invoke('save_timer_state_on_exit'); }"); + } - // ウィンドウが閉じられる前に状態を保存 - if let Some(main_window) = window.app_handle().get_webview_window("main") { - if let Err(e) = save_window_state(&main_window) { - println!("DEBUG: Failed to save window state: {}", e); + // ウィンドウが閉じられる前に状態を保存 + if let Some(main_window) = window.app_handle().get_webview_window("main") { + if let Err(e) = save_window_state(&main_window) { + println!("DEBUG: Failed to save window state: {}", e); + } + } + // メインウィンドウが閉じられた際にアプリケーション全体を終了 + std::process::exit(0); + } + WindowEvent::Moved(_) => { + // 操作ハンドルが動いたらディスプレイも追従させる + if window.label() == "layer_ctrl" { + sync_layer_to_ctrl(window.app_handle()); } } - // メインウィンドウが閉じられた際にアプリケーション全体を終了 - std::process::exit(0); + _ => {} } }) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b4cf5f9..7d4c914 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,6 +9,7 @@ "frontendDist": "../dist" }, "app": { + "macOSPrivateApi": true, "windows": [ { "label": "main", @@ -27,7 +28,7 @@ } ], "security": { - "capabilities": ["main-capability", "timeup-capability"] + "capabilities": ["main-capability", "timeup-capability", "layer-capability", "settings-capability"] } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 460d295..9780263 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { getCurrentWindow, currentMonitor, LogicalSize, PhysicalPosition } from "@tauri-apps/api/window"; +import { listen } from "@tauri-apps/api/event"; import { Store } from "@tauri-apps/plugin-store"; import TimerDisplay from "./components/TimerDisplay"; import TimerControls from "./components/TimerControls"; -import Settings from "./components/Settings"; import Help from "./components/Help"; import AboutInfo from "./components/AboutInfo"; import { TimerState, Settings as SettingsType } from "./types"; @@ -37,6 +37,9 @@ const App: React.FC = () => { alarmVolume: 0.8, displayMode: "normal", // 常に通常モードで起動 showTimeUpWindow: true, // デフォルトでTime Up画面を表示 + layerTextColor: "#00ff66", + layerShadowStyle: "dark", + layerFontSize: 6, }); // TimeUP表示の状態管理 @@ -93,8 +96,13 @@ const App: React.FC = () => { darkMode: savedSettings.darkMode ?? false, alarmSound: savedSettings.alarmSound ?? "alarm.mp3", alarmVolume: savedSettings.alarmVolume ?? 0.8, - displayMode: savedSettings.displayMode ?? (savedSettings.compactMode ? "compact" : "normal"), + displayMode: (savedSettings.displayMode === "compact" || savedSettings.displayMode === "minimal" ? savedSettings.displayMode : (savedSettings.compactMode ? "compact" : "normal")), showTimeUpWindow: savedSettings.showTimeUpWindow ?? true, + layerTextColor: savedSettings.layerTextColor ?? "#00ff66", + layerShadowStyle: savedSettings.layerShadowStyle ?? "dark", + layerFontSize: typeof savedSettings.layerFontSize === "number" && savedSettings.layerFontSize > 0 + ? savedSettings.layerFontSize + : 6, }; setSettings(convertedSettings); } @@ -115,10 +123,18 @@ const App: React.FC = () => { } }, []); - const [showSettings, setShowSettings] = useState(false); const [showHelp, setShowHelp] = useState(false); const [showAboutInfo, setShowAboutInfo] = useState(false); + const openSettings = useCallback(async () => { + if (!isTauri()) return; + try { + await invoke("show_settings_window"); + } catch (err) { + console.error("Failed to open settings window:", err); + } + }, []); + // 最後に設定した時間を記憶する状態 const [lastSetTime, setLastSetTime] = useState<{ minutes: number; @@ -422,26 +438,23 @@ const App: React.FC = () => { }; }, [timerState.minutes, timerState.seconds, lastSetTime]); - // 設定変更を処理する関数 - const handleSettingsChange = useCallback( + // 設定ウィンドウから配信された設定を反映する(保存はせず副作用は useEffect で吸収) + const applyExternalSettings = useCallback( async (newSettings: SettingsType) => { - // Always on topの設定を適用 - if (isTauri() && newSettings.alwaysOnTop !== settings.alwaysOnTop) { - try { - const currentWindow = getCurrentWindow(); - await currentWindow.setAlwaysOnTop(newSettings.alwaysOnTop); - } catch (error) { - console.error("Failed to set always on top:", error); - } - } - setSettings(newSettings); - // 設定を保存 - await saveSettings(newSettings); }, - [settings.alwaysOnTop, saveSettings] + [] ); + // alwaysOnTop はストア起動読み込み時にも外部変更時にも自動で OS 側へ反映 + useEffect(() => { + if (!isTauri()) return; + const currentWindow = getCurrentWindow(); + currentWindow.setAlwaysOnTop(settings.alwaysOnTop).catch((error) => { + console.error("Failed to apply alwaysOnTop:", error); + }); + }, [settings.alwaysOnTop]); + const handlePowerButtonClick = async () => { if (isTauri()) { try { @@ -487,61 +500,87 @@ const App: React.FC = () => { } }; - const handleCompactModeToggle = useCallback(async () => { - // 通常 → 簡易 → ミニマム → 通常の順で循環 - const nextMode: "normal" | "compact" | "minimal" = - settings.displayMode === "normal" ? "compact" : - settings.displayMode === "compact" ? "minimal" : - "normal"; + // 表示モード遷移の共通ロジック + const transitionToMode = useCallback( + async (nextMode: "normal" | "compact" | "minimal") => { + const prevMode = settings.displayMode; + if (prevMode === nextMode) return; - const newSettings = { ...settings, displayMode: nextMode }; - setSettings(newSettings); - // 表示モードの状態は保存しない + const newSettings = { ...settings, displayMode: nextMode }; + setSettings(newSettings); + // 表示モードの状態は保存しない + + if (!isTauri()) return; + + const currentWindow = getCurrentWindow(); - // ウィンドウサイズを変更 - if (isTauri()) { try { + // フルスクリーン中はモード切り替え時に解除 + if (isFullscreen) { + setIsFullscreen(false); + if (savedWindowGeometryRef.current) { + const { x, y } = savedWindowGeometryRef.current; + await currentWindow.setPosition(new PhysicalPosition(x, y)); + savedWindowGeometryRef.current = null; + } + } + let width: number; let height: number; - if (nextMode === "normal") { width = 800; height = 200; } else if (nextMode === "compact") { width = 400; height = 200; - } else { // minimal + } else { + // minimal width = 200; height = 100; } - const currentWindow = getCurrentWindow(); - - // フルスクリーン中はモード切り替え時に解除 - if (isFullscreen) { - setIsFullscreen(false); - // 保存しておいた位置に戻す - if (savedWindowGeometryRef.current) { - const { x, y } = savedWindowGeometryRef.current; - await currentWindow.setPosition(new PhysicalPosition(x, y)); - savedWindowGeometryRef.current = null; - } - } - - // サイズ制約を一旦解除してからリサイズ await currentWindow.setMinSize(null); await currentWindow.setMaxSize(null); await invoke("set_window_size", { width, height }); - - // min/maxサイズ制約でリサイズを防止 const size = new LogicalSize(width, height); await currentWindow.setMinSize(size); await currentWindow.setMaxSize(size); } catch (error) { - console.error("Failed to set window size:", error); + console.error("Failed to transition display mode:", error); } + }, + [settings, isFullscreen] + ); + + const handleCompactModeToggle = useCallback(async () => { + // 通常 → 簡易 → ミニマム → 通常の順で循環 + const nextMode: "normal" | "compact" | "minimal" = + settings.displayMode === "normal" ? "compact" : + settings.displayMode === "compact" ? "minimal" : + "normal"; + await transitionToMode(nextMode); + }, [settings.displayMode, transitionToMode]); + + // レイヤー表示の有効/無効(永続化しない) + const [layerEnabled, setLayerEnabled] = useState(false); + + const toggleLayer = useCallback(async () => { + if (!isTauri()) { + setLayerEnabled((v) => !v); + return; + } + const next = !layerEnabled; + try { + if (next) { + await invoke("show_layer_window"); + } else { + await invoke("hide_layer_window"); + } + setLayerEnabled(next); + } catch (error) { + console.error("Failed to toggle layer:", error); } - }, [settings, isFullscreen]); + }, [layerEnabled]); // フルスクリーン切り替え(手動フルスクリーン:ネイティブのsetFullscreenはmacOSデュアルディスプレイで問題があるため使わない) const handleFullscreenToggle = useCallback(async () => { @@ -695,62 +734,20 @@ const App: React.FC = () => { // Vキーで表示モード切り替え(通常 → 簡易 → ミニマム → 通常) else if (key === "v") { event.preventDefault(); - - // 通常 → 簡易 → ミニマム → 通常の順で循環 const nextMode: "normal" | "compact" | "minimal" = settings.displayMode === "normal" ? "compact" : settings.displayMode === "compact" ? "minimal" : "normal"; + await transitionToMode(nextMode); + } - const newSettings = { ...settings, displayMode: nextMode }; - setSettings(newSettings); - - // ウィンドウサイズを変更 - if (isTauri()) { - try { - let width: number; - let height: number; - - if (nextMode === "normal") { - width = 800; - height = 200; - } else if (nextMode === "compact") { - width = 400; - height = 200; - } else { // minimal - width = 200; - height = 100; - } - - const currentWindow = getCurrentWindow(); - - // フルスクリーン中はモード切り替え時に解除 - if (isFullscreen) { - setIsFullscreen(false); - // 保存しておいた位置に戻す - if (savedWindowGeometryRef.current) { - const { x, y } = savedWindowGeometryRef.current; - await currentWindow.setPosition(new PhysicalPosition(x, y)); - savedWindowGeometryRef.current = null; - } - } - - // サイズ制約を一旦解除してからリサイズ - await currentWindow.setMinSize(null); - await currentWindow.setMaxSize(null); - await invoke("set_window_size", { width, height }); - - // min/maxサイズ制約でリサイズを防止 - const size = new LogicalSize(width, height); - await currentWindow.setMinSize(size); - await currentWindow.setMaxSize(size); - } catch (error) { - console.error("Failed to set window size:", error); - } - } + // Lキーでレイヤー表示の ON/OFF 切り替え + else if (key === "l") { + event.preventDefault(); + await toggleLayer(); } }, - [timerState, startTimer, pauseTimer, resetTimer, updateTimerBoth, settings, stopAlarm, handleFullscreenToggle, isFullscreen] + [timerState, startTimer, pauseTimer, resetTimer, updateTimerBoth, settings.displayMode, stopAlarm, handleFullscreenToggle, transitionToMode, toggleLayer] ); // キーボードイベントリスナーの設定 @@ -801,6 +798,101 @@ const App: React.FC = () => { }; }, [stopAlarm]); + // レイヤー表示中、タイマー状態をオーバーレイへ直接反映 (webview.eval 経由) + useEffect(() => { + if (!isTauri()) return; + if (!layerEnabled) return; + + invoke("update_layer_timer", { + minutes: timerState.minutes, + seconds: timerState.seconds, + showTimeUp, + }).catch((error) => { + console.error("Failed to update layer timer:", error); + }); + }, [ + layerEnabled, + timerState.minutes, + timerState.seconds, + showTimeUp, + ]); + + // レイヤー表示中、文字色・影スタイルの変更を反映 + // (event だと到達しないことがあるため Rust の webview.eval で直接更新) + useEffect(() => { + if (!isTauri()) return; + if (!layerEnabled) return; + + invoke("update_layer_style", { + color: settings.layerTextColor, + shadow: settings.layerShadowStyle, + fontSize: settings.layerFontSize, + }).catch((error) => { + console.error("Failed to update layer style:", error); + }); + }, [layerEnabled, settings.layerTextColor, settings.layerShadowStyle, settings.layerFontSize]); + + // レイヤーウィンドウからの退出要求を受信 + useEffect(() => { + if (!isTauri()) return; + + const unlistenPromise = listen("layer-exit-requested", () => { + invoke("hide_layer_window").catch(() => {}); + setLayerEnabled(false); + }); + + return () => { + unlistenPromise.then((unlisten) => unlisten()).catch(() => {}); + }; + }, []); + + // 設定ウィンドウからの設定変更を反映 + useEffect(() => { + if (!isTauri()) return; + + const unlistenPromise = listen("settings-changed", (event) => { + applyExternalSettings(event.payload); + }); + + return () => { + unlistenPromise.then((unlisten) => unlisten()).catch(() => {}); + }; + }, [applyExternalSettings]); + + // レイヤーウィンドウ初期化時に現在値とスタイルを即時送信 + useEffect(() => { + if (!isTauri()) return; + + const unlistenPromise = listen("layer-ready", () => { + invoke("update_layer_style", { + color: settings.layerTextColor, + shadow: settings.layerShadowStyle, + fontSize: settings.layerFontSize, + }).catch((error) => { + console.error("Failed to update layer style on ready:", error); + }); + invoke("update_layer_timer", { + minutes: timerState.minutes, + seconds: timerState.seconds, + showTimeUp, + }).catch((error) => { + console.error("Failed to update layer timer on ready:", error); + }); + }); + + return () => { + unlistenPromise.then((unlisten) => unlisten()).catch(() => {}); + }; + }, [ + timerState.minutes, + timerState.seconds, + timerState.isRunning, + showTimeUp, + settings.layerTextColor, + settings.layerShadowStyle, + settings.layerFontSize, + ]); + return (
{ "⊞"} + {/* レイヤー表示 ON/OFF ボタン */} + + {/* フルスクリーン切り替えボタン(右上) - 簡易モードのみ表示 */} {settings.displayMode === "compact" && (
- {showSettings && ( - setShowSettings(false)} - onSettingsChange={handleSettingsChange} - /> - )} - {showHelp && ( setShowHelp(false)} /> )} diff --git a/src/SettingsApp.tsx b/src/SettingsApp.tsx new file mode 100644 index 0000000..9dd3be7 --- /dev/null +++ b/src/SettingsApp.tsx @@ -0,0 +1,376 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { emit, listen } from "@tauri-apps/api/event"; +import { Store } from "@tauri-apps/plugin-store"; +import { Settings as SettingsType } from "./types"; + +const DEFAULT_SETTINGS: SettingsType = { + alwaysOnTop: false, + darkMode: false, + alarmSound: "alarm.mp3", + alarmVolume: 0.8, + displayMode: "normal", + showTimeUpWindow: true, + layerTextColor: "#00ff66", + layerShadowStyle: "dark", + layerFontSize: 6, +}; + +const LAYER_FONT_SIZE_MIN = 2; +const LAYER_FONT_SIZE_MAX = 14; +const LAYER_FONT_SIZE_STEP = 0.5; + +const ALARM_SOUNDS = [ + { value: "alarm.mp3", label: "Alarm" }, + { value: "gong.mp3", label: "Gong" }, + { value: "marimba.mp3", label: "Marimba" }, + { value: "pulse.mp3", label: "Pulse" }, + { value: "symbal.mp3", label: "Symbal" }, +]; + +const COLOR_PRESETS = [ + "#00ff66", + "#ffffff", + "#000000", + "#ffeb3b", + "#ff5252", + "#40c4ff", + "#ff9800", + "#e040fb", +]; + +function normalizeSettings( + saved: (Partial & { compactMode?: boolean }) | null +): SettingsType { + if (!saved) return DEFAULT_SETTINGS; + return { + alwaysOnTop: saved.alwaysOnTop ?? DEFAULT_SETTINGS.alwaysOnTop, + darkMode: saved.darkMode ?? DEFAULT_SETTINGS.darkMode, + alarmSound: saved.alarmSound ?? DEFAULT_SETTINGS.alarmSound, + alarmVolume: saved.alarmVolume ?? DEFAULT_SETTINGS.alarmVolume, + displayMode: + saved.displayMode === "compact" || saved.displayMode === "minimal" + ? saved.displayMode + : saved.compactMode + ? "compact" + : "normal", + showTimeUpWindow: saved.showTimeUpWindow ?? DEFAULT_SETTINGS.showTimeUpWindow, + layerTextColor: saved.layerTextColor ?? DEFAULT_SETTINGS.layerTextColor, + layerShadowStyle: saved.layerShadowStyle ?? DEFAULT_SETTINGS.layerShadowStyle, + layerFontSize: + typeof saved.layerFontSize === "number" && saved.layerFontSize > 0 + ? saved.layerFontSize + : DEFAULT_SETTINGS.layerFontSize, + }; +} + +const SettingsApp: React.FC = () => { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loaded, setLoaded] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const audioRef = useRef(null); + + // 起動時にストアから読み込み + useEffect(() => { + (async () => { + if (!isTauri()) { + setLoaded(true); + return; + } + try { + const store = await Store.load("settings.json"); + const saved = await store.get< + Partial & { compactMode?: boolean } + >("settings"); + setSettings(normalizeSettings(saved ?? null)); + } catch (err) { + console.error("Failed to load settings:", err); + } + setLoaded(true); + })(); + }, []); + + // ダークモードを設定ウィンドウにも反映 + useEffect(() => { + document.documentElement.classList.toggle("dark", settings.darkMode); + document.documentElement.classList.toggle("light", !settings.darkMode); + }, [settings.darkMode]); + + // メインからの設定プッシュを反映(同期保つ) + useEffect(() => { + if (!isTauri()) return; + const p = listen("settings-pushed", (event) => { + setSettings(event.payload); + }); + return () => { + p.then((u) => u()).catch(() => {}); + }; + }, []); + + const persist = useCallback(async (next: SettingsType) => { + setSettings(next); + if (!isTauri()) return; + try { + const store = await Store.load("settings.json"); + await store.set("settings", next); + await store.save(); + // メインへ通知(emitTo で main に直接配信) + try { + const { emitTo } = await import("@tauri-apps/api/event"); + await emitTo("main", "settings-changed", next); + } catch { + await emit("settings-changed", next); + } + } catch (err) { + console.error("Failed to save settings:", err); + } + }, []); + + const update = useCallback( + (key: K, value: SettingsType[K]) => { + void persist({ ...settings, [key]: value }); + }, + [persist, settings] + ); + + const handleClose = useCallback(async () => { + if (!isTauri()) return; + try { + await invoke("hide_settings_window"); + } catch (err) { + console.error("Failed to close settings window:", err); + } + }, []); + + const handleTestSound = useCallback(() => { + if (isPlaying) { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + audioRef.current = null; + } + setIsPlaying(false); + return; + } + const audio = new Audio(`/sounds/${settings.alarmSound}`); + audio.volume = settings.alarmVolume; + audioRef.current = audio; + audio.addEventListener("ended", () => { + setIsPlaying(false); + audioRef.current = null; + }); + audio.addEventListener("error", () => { + console.error("Failed to play test sound"); + setIsPlaying(false); + audioRef.current = null; + }); + audio + .play() + .then(() => setIsPlaying(true)) + .catch((err) => { + console.error("Failed to play test sound:", err); + setIsPlaying(false); + audioRef.current = null; + }); + }, [isPlaying, settings.alarmSound, settings.alarmVolume]); + + // Esc キーで閉じる + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + void handleClose(); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [handleClose]); + + if (!loaded) { + return
Loading…
; + } + + return ( +
+
+

Settings

+ +
+ +
+
+

Window

+
+ +

タイマーウィンドウを常に最前面に表示します

+
+
+ +

UI のテーマを切り替えます

+
+
+ +

タイマー終了時に全画面の "Time Up" 画面を表示します

+
+
+ +
+

Alarm

+
+ Sound +
+ + +
+
+
+ +
+

Layer overlay

+
+ Text color +
+ update("layerTextColor", e.target.value)} + /> + update("layerTextColor", e.target.value)} + spellCheck={false} + /> +
+ {COLOR_PRESETS.map((color) => ( +
+
+

+ レイヤー表示の数字色を選びます。背景がはっきりしない場面に合わせて調整できます。 +

+
+
+ Shadow +
+ + +
+
+
+ + Font size + {settings.layerFontSize.toFixed(1)} rem + +
+ update("layerFontSize", parseFloat(e.target.value))} + /> + +
+
+
+
+ 12:34 +
+

プレビュー

+
+
+
+
+ ); +}; + +export default SettingsApp; diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx deleted file mode 100644 index 735a82a..0000000 --- a/src/components/Settings.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useState, useRef } from "react"; -import { SettingsProps } from "../types"; - -const Settings: React.FC = ({ - settings, - onClose, - onSettingsChange, -}) => { - const [isPlaying, setIsPlaying] = useState(false); - const audioRef = useRef(null); - const handleAlwaysOnTopChange = (checked: boolean) => { - onSettingsChange({ - ...settings, - alwaysOnTop: checked, - }); - }; - - const handleDarkModeChange = (checked: boolean) => { - onSettingsChange({ - ...settings, - darkMode: checked, - }); - }; - - const handleAlarmSoundChange = (sound: string) => { - // 再生中の場合、停止する - if (isPlaying && audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - audioRef.current = null; - setIsPlaying(false); - } - - onSettingsChange({ - ...settings, - alarmSound: sound, - }); - }; - - const handleShowTimeUpWindowChange = (checked: boolean) => { - onSettingsChange({ - ...settings, - showTimeUpWindow: checked, - }); - }; - - const handleTestSound = () => { - if (isPlaying) { - // 停止 - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - audioRef.current = null; - } - setIsPlaying(false); - } else { - // 再生 - const audio = new Audio(`/sounds/${settings.alarmSound}`); - audio.volume = settings.alarmVolume; - audioRef.current = audio; - - audio.addEventListener('ended', () => { - setIsPlaying(false); - audioRef.current = null; - }); - - audio.addEventListener('error', () => { - console.error("Failed to play test sound"); - setIsPlaying(false); - audioRef.current = null; - }); - - audio.play().then(() => { - setIsPlaying(true); - }).catch((error) => { - console.error("Failed to play test sound:", error); - setIsPlaying(false); - audioRef.current = null; - }); - } - }; - - // アラーム音の選択肢 - const alarmSounds = [ - { value: "alarm.mp3", label: "alarm" }, - { value: "gong.mp3", label: "gong" }, - { value: "marimba.mp3", label: "marimba" }, - { value: "pulse.mp3", label: "pulse" }, - { value: "symbal.mp3", label: "symbal" }, - ]; - - return ( -
-
-
-

Settings

- -
- -
-
-
- - - -
-
- -
- -
-
-
-
- ); -}; - -export default Settings; diff --git a/src/index.css b/src/index.css index e1b51e0..a2c34f3 100644 --- a/src/index.css +++ b/src/index.css @@ -163,6 +163,39 @@ button { transform: scale(1.1); } +/* レイヤー表示 ON/OFF ボタン */ +.layer-toggle-button { + position: absolute; + top: 8px; + left: 56px; + width: 16px; + height: 16px; + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + transition: all 0.2s ease; + z-index: 1000; + font-size: 12px; + color: var(--text-secondary); + font-weight: bold; +} + +.layer-toggle-button:hover { + background-color: rgba(0, 122, 204, 0.1); + color: var(--accent); + transform: scale(1.1); +} + +.layer-toggle-button.active { + color: var(--accent); + background-color: rgba(0, 122, 204, 0.18); +} + /* フルスクリーン切り替えボタン(右上) */ .fullscreen-toggle-button { position: absolute; @@ -902,6 +935,89 @@ button { background-color: var(--accent-hover); } +/* レイヤー表示のスタイル設定 */ +.layer-style-section { + flex-direction: column; + align-items: stretch; + gap: 10px; +} + +.layer-style-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.layer-style-label { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + min-width: 130px; +} + +.layer-color-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.layer-color-picker { + width: 32px; + height: 24px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: none; + cursor: pointer; +} + +.layer-color-presets { + display: flex; + gap: 4px; +} + +.layer-color-swatch { + width: 18px; + height: 18px; + border: 1px solid var(--border); + border-radius: 50%; + cursor: pointer; + padding: 0; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.layer-color-swatch:hover { + transform: scale(1.15); +} + +.layer-color-swatch.selected { + border-color: var(--accent); + border-width: 2px; + box-shadow: 0 0 0 1px var(--bg-secondary); +} + +.layer-shadow-controls { + display: flex; + gap: 14px; +} + +.layer-shadow-controls label { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 4px !important; + cursor: pointer; + text-transform: none !important; + letter-spacing: normal !important; + font-weight: normal !important; + color: var(--text-primary) !important; + font-size: 0.9rem !important; +} + .alarm-sound-selector label { flex-direction: column; align-items: flex-start; diff --git a/src/settings-main.tsx b/src/settings-main.tsx new file mode 100644 index 0000000..02a6408 --- /dev/null +++ b/src/settings-main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import SettingsApp from "./SettingsApp"; +import "./index.css"; +import "./settings-page.css"; + +ReactDOM.createRoot(document.getElementById("settings-root") as HTMLElement).render( + + + +); diff --git a/src/settings-page.css b/src/settings-page.css new file mode 100644 index 0000000..c637156 --- /dev/null +++ b/src/settings-page.css @@ -0,0 +1,383 @@ +/* 設定ウィンドウ専用スタイル */ + +html, body { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +#settings-root { + width: 100vw; + height: 100vh; + overflow: hidden; + background-color: var(--bg-primary); + color: var(--text-primary); +} + +.settings-page-loading { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--text-secondary); +} + +.settings-page { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background-color: var(--bg-primary); + color: var(--text-primary); +} + +.settings-page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background-color: var(--bg-secondary); +} + +.settings-page-header h1 { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + margin: 0; +} + +.settings-page-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + font-size: 18px; + color: var(--text-secondary); + transition: all 0.15s ease; +} + +.settings-page-close:hover { + background-color: var(--bg-primary); + border-color: var(--border); + color: var(--text-primary); +} + +.settings-page-body { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 20px; + background-color: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; +} + +.settings-section-title { + margin: 0 0 4px 0; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-secondary); +} + +.settings-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.settings-row-inline { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.settings-row-label { + font-size: 0.85rem; + color: var(--text-primary); + font-weight: 600; +} + +.settings-row-control { + display: flex; + align-items: center; + gap: 8px; +} + +.settings-row-hint { + margin: 2px 0 0 0; + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.4; +} + +/* トグル */ +.settings-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.92rem; + color: var(--text-primary); + user-select: none; +} + +.settings-toggle input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--accent); +} + +/* セレクト */ +.settings-select { + flex: 1; + min-width: 0; + max-width: 240px; + padding: 7px 10px; + background-color: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; +} + +.settings-icon-button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--accent); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.15s ease; +} + +.settings-icon-button:hover { + background-color: var(--accent-hover); +} + +/* 色グループ */ +.settings-color-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 4px; +} + +.settings-color-picker { + width: 40px; + height: 30px; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: none; + cursor: pointer; +} + +.settings-color-hex { + width: 96px; + padding: 6px 8px; + background-color: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + font-family: 'Menlo', 'Courier New', monospace; + font-size: 0.85rem; +} + +.settings-color-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.settings-color-swatch { + width: 22px; + height: 22px; + border: 1px solid var(--border); + border-radius: 50%; + cursor: pointer; + padding: 0; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.settings-color-swatch:hover { + transform: scale(1.15); +} + +.settings-color-swatch.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--bg-secondary), 0 0 0 4px var(--accent); +} + +/* ラジオグループ */ +.settings-radio-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} + +.settings-radio { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.9rem; + color: var(--text-primary); + user-select: none; +} + +.settings-radio input[type="radio"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--accent); +} + +.settings-radio-hint { + font-size: 0.75rem; + color: var(--text-secondary); +} + +/* レイヤープレビュー */ +.settings-layer-preview { + display: flex; + align-items: center; + justify-content: center; + height: 220px; + margin-top: 6px; + background-color: #4a4a4a; + background-image: repeating-conic-gradient( + #5a5a5a 0% 25%, + #3f3f3f 0% 50% + ); + background-size: 24px 24px; + background-position: 50% 50%; + border: 1px solid var(--border); + border-radius: 8px; + font-family: 'MesloPowerline', 'Menlo', 'Courier New', monospace; + font-weight: bold; + letter-spacing: 0.05em; + line-height: 1; + overflow: hidden; +} + +/* スライダー */ +.settings-slider-group { + display: flex; + align-items: center; + gap: 12px; + margin-top: 6px; +} + +.settings-slider { + flex: 1; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--border); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.settings-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--accent); + border: none; + border-radius: 50%; + cursor: pointer; +} + +.settings-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--accent); + border: none; + border-radius: 50%; + cursor: pointer; +} + +.settings-text-button { + padding: 5px 12px; + font-size: 0.78rem; + background: none; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.settings-text-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.settings-row-value { + margin-left: 8px; + font-family: 'Menlo', 'Courier New', monospace; + font-size: 0.82rem; + font-weight: 500; + color: var(--text-secondary); + text-transform: none; + letter-spacing: normal; +} + +.settings-layer-preview[data-shadow="dark"] { + text-shadow: + 0 0 8px rgba(0, 0, 0, 0.9), + 0 0 16px rgba(0, 0, 0, 0.7), + 0 2px 4px rgba(0, 0, 0, 1); +} + +.settings-layer-preview[data-shadow="light"] { + text-shadow: + 0 0 8px rgba(255, 255, 255, 0.95), + 0 0 16px rgba(255, 255, 255, 0.8), + 0 2px 4px rgba(255, 255, 255, 1); +} + +/* スクロールバー(macOS風の控えめ) */ +.settings-page-body::-webkit-scrollbar { + width: 10px; +} + +.settings-page-body::-webkit-scrollbar-thumb { + background-color: rgba(127, 127, 127, 0.35); + border-radius: 5px; +} + +.settings-page-body::-webkit-scrollbar-thumb:hover { + background-color: rgba(127, 127, 127, 0.55); +} diff --git a/src/types.ts b/src/types.ts index f4bce8e..e3e16ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,9 @@ export interface Settings { alarmVolume: number; displayMode: "normal" | "compact" | "minimal"; showTimeUpWindow: boolean; + layerTextColor: string; + layerShadowStyle: "dark" | "light"; + layerFontSize: number; } export interface TimerDisplayProps { @@ -38,8 +41,3 @@ export interface TimerControlsProps { onAlarmVolumeChange: (volume: number) => void; } -export interface SettingsProps { - settings: Settings; - onClose: () => void; - onSettingsChange: (settings: Settings) => void; -} diff --git a/vite.config.ts b/vite.config.ts index 0c9adff..b9e1059 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,20 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { resolve } from "path"; // https://vitejs.dev/config/ export default defineConfig(async () => ({ plugins: [react()], + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + settings: resolve(__dirname, "settings.html"), + }, + }, + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent vite from obscuring rust errors From 39791a1230f4494d03d39e30042b215431a49fb0 Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 14 Apr 2026 11:39:28 +0900 Subject: [PATCH 2/4] Fixed issues with layer modes and added i18n support --- public/layer_ctrl.html | 78 --------------------------- public/timeup.html | 29 +++++++++- src-tauri/src/main.rs | 6 ++- src/App.tsx | 81 +++++++++++++++++++--------- src/SettingsApp.tsx | 93 ++++++++++++++++++++++---------- src/components/AboutInfo.tsx | 11 ++-- src/components/Help.tsx | 24 +++++---- src/components/TimerControls.tsx | 7 ++- src/i18n/en.ts | 86 +++++++++++++++++++++++++++++ src/i18n/index.ts | 58 ++++++++++++++++++++ src/i18n/ja.ts | 83 ++++++++++++++++++++++++++++ src/i18n/useTranslation.ts | 26 +++++++++ src/index.css | 6 --- src/main.tsx | 29 ++++++++-- src/settings-main.tsx | 29 ++++++++-- src/types.ts | 1 + 16 files changed, 481 insertions(+), 166 deletions(-) create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/ja.ts create mode 100644 src/i18n/useTranslation.ts diff --git a/public/layer_ctrl.html b/public/layer_ctrl.html index 235b1e5..67581fa 100644 --- a/public/layer_ctrl.html +++ b/public/layer_ctrl.html @@ -27,7 +27,6 @@ display: flex; align-items: center; justify-content: center; - gap: 4px; padding: 0 6px; background: rgba(20, 20, 20, 0.72); border: 1px solid rgba(255, 255, 255, 0.18); @@ -56,34 +55,6 @@ letter-spacing: 1.5px; pointer-events: none; } - - .close { - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.95); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - font-size: 14px; - line-height: 1; - cursor: pointer; - padding: 0; - -webkit-app-region: no-drag; - app-region: no-drag; - } - - .close:hover { - background: rgba(255, 60, 60, 0.85); - color: #fff; - border-color: rgba(255, 120, 120, 0.8); - } - - .close:active { - transform: scale(0.92); - } @@ -91,55 +62,6 @@
⋮⋮
- - - diff --git a/public/timeup.html b/public/timeup.html index 2314a02..2ad494d 100644 --- a/public/timeup.html +++ b/public/timeup.html @@ -80,8 +80,8 @@
Time Up!!
-
The time has come.
-
Click or press the Esc key to close
+
The time has come.
+
Click or press the Esc key to close