diff --git a/public/welcome-artwork.png b/public/welcome-artwork.png new file mode 100644 index 0000000..e8f476f Binary files /dev/null and b/public/welcome-artwork.png differ diff --git a/src-tauri/src/data/settings.rs b/src-tauri/src/data/settings.rs index dcbb08a..ca28ab3 100644 --- a/src-tauri/src/data/settings.rs +++ b/src-tauri/src/data/settings.rs @@ -15,6 +15,9 @@ pub struct GeneralSettings { /// Whether the editor window floats above other windows #[serde(default = "default_editor_always_on_top")] pub editor_always_on_top: bool, + /// Whether the welcome screen has been dismissed (don't show again) + #[serde(default)] + pub welcome_screen_dismissed: bool, } /// Default hotkey: Cmd/Ctrl+Shift+Space @@ -33,6 +36,7 @@ impl Default for GeneralSettings { auto_launch: false, hotkey: default_hotkey(), editor_always_on_top: default_editor_always_on_top(), + welcome_screen_dismissed: false, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf48b1d..1fa25c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -86,6 +86,29 @@ pub fn run() { } } + // Check if welcome screen should show + let show_welcome = crate::os::welcome::should_show_welcome(); + + if show_welcome { + // Hide the launcher (it starts hidden in tauri.conf.json but may be visible) + if let Some(launcher) = app.get_webview_window("launcher") { + let _ = launcher.hide(); + } + + // Open welcome window + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::os::welcome::open_welcome_window(app_handle).await { + eprintln!("Failed to open welcome window: {}", e); + } + }); + } else { + // Show launcher immediately if welcome is dismissed + if let Some(launcher) = app.get_webview_window("launcher") { + let _ = launcher.show(); + } + } + // Register global shortcut from settings (defaults to Cmd+Shift+Space) let app_handle = app.handle(); if let Err(e) = init_hotkey_from_settings(app_handle) { @@ -126,6 +149,9 @@ pub fn run() { os::paste::copy_as_markdown_file, os::window::open_editor_window, os::window::close_editor_window, + // Welcome window commands + os::welcome::open_welcome_window, + os::welcome::close_welcome_window, // Hotkey commands os::hotkey::get_current_hotkey, os::hotkey::set_hotkey, @@ -142,6 +168,14 @@ pub fn run() { // Handle dock icon click on macOS #[cfg(target_os = "macos")] if let RunEvent::Reopen { .. } = _event { + // If welcome window is visible, focus it + if crate::os::welcome::is_welcome_visible(_app_handle) { + if let Some(welcome) = _app_handle.get_webview_window("welcome") { + let _ = welcome.set_focus(); + } + return; + } + // Check if editor window exists and is visible if let Some(editor) = _app_handle.get_webview_window("editor") { if editor.is_visible().unwrap_or(false) { diff --git a/src-tauri/src/os/hotkey.rs b/src-tauri/src/os/hotkey.rs index d36baa8..1cc08a8 100644 --- a/src-tauri/src/os/hotkey.rs +++ b/src-tauri/src/os/hotkey.rs @@ -178,6 +178,14 @@ pub fn register_hotkey(app: &AppHandle, hotkey_str: &str) -> Result<(), String> return; } + // Dismiss welcome window if visible + if crate::os::welcome::is_welcome_visible(&app_handle) { + if let Some(welcome) = app_handle.get_webview_window("welcome") { + let _ = welcome.close(); + } + // Note: Don't save dismissal preference here - let user decide via checkbox + } + if let Some(window) = app_handle.get_webview_window("launcher") { // Toggle window visibility if window.is_visible().unwrap_or(false) { diff --git a/src-tauri/src/os/mod.rs b/src-tauri/src/os/mod.rs index d4c67a8..6ebe544 100644 --- a/src-tauri/src/os/mod.rs +++ b/src-tauri/src/os/mod.rs @@ -3,4 +3,5 @@ pub mod hotkey; pub mod paste; pub mod platform; pub mod previous_app; +pub mod welcome; pub mod window; diff --git a/src-tauri/src/os/welcome.rs b/src-tauri/src/os/welcome.rs new file mode 100644 index 0000000..99e51ec --- /dev/null +++ b/src-tauri/src/os/welcome.rs @@ -0,0 +1,114 @@ +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +use crate::data::settings::AppSettings; +use crate::os::focus::get_key_window_screen_bounds; + +const WELCOME_WIDTH: f64 = 900.0; +const WELCOME_HEIGHT: f64 = 604.0; + +/// Check if welcome screen should be shown based on settings +pub fn should_show_welcome() -> bool { + let settings = AppSettings::load(); + !settings.general.welcome_screen_dismissed +} + +/// Check if welcome window is currently visible +pub fn is_welcome_visible(app: &AppHandle) -> bool { + app.get_webview_window("welcome") + .map(|w| w.is_visible().unwrap_or(false)) + .unwrap_or(false) +} + +/// Open the welcome screen window +#[tauri::command] +pub async fn open_welcome_window(app: AppHandle) -> Result<(), String> { + let label = "welcome"; + + // If window already exists, just show and focus it + if let Some(window) = app.get_webview_window(label) { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + // Create the welcome window + let mut builder = WebviewWindowBuilder::new( + &app, + label, + WebviewUrl::App("index.html?window=welcome".into()), + ) + .title("Welcome to PromptLight") + .inner_size(WELCOME_WIDTH, WELCOME_HEIGHT) + .resizable(false) + .decorations(false) + .always_on_top(true); + + // Position on the screen with the active/key window + if let Some(bounds) = get_key_window_screen_bounds() { + let x = bounds.x + (bounds.width - WELCOME_WIDTH) / 2.0; + let y = bounds.y + (bounds.height - WELCOME_HEIGHT) / 2.0; + builder = builder.position(x, y); + } else { + builder = builder.center(); + } + + // Disable minimize/maximize on macOS + #[cfg(target_os = "macos")] + { + builder = builder.minimizable(false).maximizable(false); + } + + let window = builder.build().map_err(|e| e.to_string())?; + + // Apply transparent background (required for rounded corners on macOS) + #[cfg(target_os = "macos")] + { + use cocoa::appkit::{NSColor, NSWindow}; + use cocoa::base::{id, nil}; + use cocoa::foundation::NSString; + + // First, disable the webview's background drawing + let _ = window.with_webview(|webview| { + unsafe { + let wv: id = webview.inner().cast(); + let no: id = msg_send![class!(NSNumber), numberWithBool: false]; + let key = NSString::alloc(nil).init_str("drawsBackground"); + let _: () = msg_send![wv, setValue: no forKey: key]; + } + }); + + // Then set the window background to clear + let ns_window = window.ns_window().unwrap() as id; + unsafe { + let clear_color: id = NSColor::clearColor(nil); + NSWindow::setBackgroundColor_(ns_window, clear_color); + let _: () = msg_send![ns_window, setOpaque: false]; + } + } + + Ok(()) +} + +/// Close the welcome window and optionally save the "don't show again" preference +#[tauri::command] +pub fn close_welcome_window(app: AppHandle, dont_show_again: bool) -> Result<(), String> { + // Save preference if "Don't show again" was checked + if dont_show_again { + let mut settings = AppSettings::load(); + settings.general.welcome_screen_dismissed = true; + settings.save()?; + } + + // Close the window + if let Some(window) = app.get_webview_window("welcome") { + window.close().map_err(|e| e.to_string())?; + } + + // Show the launcher window + if let Some(launcher) = app.get_webview_window("launcher") { + launcher.show().map_err(|e| e.to_string())?; + launcher.set_focus().map_err(|e| e.to_string())?; + } + + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 970f347..5dc5b19 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ "transparent": true, "backgroundColor": [0, 0, 0, 0], "alwaysOnTop": true, - "visible": true, + "visible": false, "skipTaskbar": true, "focus": true } diff --git a/src/App.tsx b/src/App.tsx index 5da85dc..ab71a2d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; import { LauncherWindow } from './components/launcher'; import { EditorWindow } from './components/editor'; +import { WelcomeWindow } from './components/welcome'; import { MarkdownEditorTest } from './components/editor/PromptEditor/MarkdownEditor/MarkdownEditorTest'; import { useLauncher, useUIScale } from './hooks'; import { useSettingsStore } from './stores/settingsStore'; -type WindowType = 'launcher' | 'editor' | 'test' | null; +type WindowType = 'launcher' | 'editor' | 'welcome' | 'test' | null; function App() { const [windowType, setWindowType] = useState(null); @@ -25,6 +26,8 @@ function App() { const windowParam = params.get('window'); if (windowParam === 'editor') { setWindowType('editor'); + } else if (windowParam === 'welcome') { + setWindowType('welcome'); } else if (windowParam === 'test') { setWindowType('test'); } else { @@ -42,6 +45,10 @@ function App() { return ; } + if (windowType === 'welcome') { + return ; + } + if (windowType === 'test') { return ; } diff --git a/src/components/launcher/ResultItem.tsx b/src/components/launcher/ResultItem.tsx index 0f92309..74fce90 100644 --- a/src/components/launcher/ResultItem.tsx +++ b/src/components/launcher/ResultItem.tsx @@ -32,6 +32,10 @@ export function ResultItem({ result, isSelected, index }: ResultItemProps) { executeSelected(); }; + const handleMouseEnter = () => { + setSelectedIndex(index); + }; + const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -44,6 +48,7 @@ export function ResultItem({ result, isSelected, index }: ResultItemProps) { ref={itemRef} className={`${styles.item} ${isSelected ? styles.selected : ''}`} onClick={handleClick} + onMouseEnter={handleMouseEnter} onContextMenu={handleContextMenu} role="option" aria-selected={isSelected} diff --git a/src/components/welcome/WelcomeWindow.module.css b/src/components/welcome/WelcomeWindow.module.css new file mode 100644 index 0000000..d6515ac --- /dev/null +++ b/src/components/welcome/WelcomeWindow.module.css @@ -0,0 +1,75 @@ +.container { + position: relative; + width: 100%; + height: 100vh; + overflow: hidden; + border-radius: var(--radius-lg); + background: #1a1a1a; +} + +.artwork { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.controls { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); +} + +.letsGoButton { + padding: var(--space-3) var(--space-8); + background: #6b7c3f; + color: #ffffff; + border: none; + border-radius: var(--radius-md); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: background var(--transition-fast), transform var(--transition-fast); + min-width: 160px; +} + +.letsGoButton:hover:not(:disabled) { + background: #7a8c4a; + transform: scale(1.02); +} + +.letsGoButton:active:not(:disabled) { + transform: scale(0.98); +} + +.letsGoButton:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.letsGoButton:focus-visible { + outline: 2px solid #6b7c3f; + outline-offset: 2px; +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: var(--space-2); + color: rgba(255, 255, 255, 0.85); + font-size: var(--font-size-sm); + cursor: pointer; + user-select: none; +} + +.checkbox { + width: 16px; + height: 16px; + accent-color: #6b7c3f; + cursor: pointer; +} diff --git a/src/components/welcome/WelcomeWindow.tsx b/src/components/welcome/WelcomeWindow.tsx new file mode 100644 index 0000000..1c69643 --- /dev/null +++ b/src/components/welcome/WelcomeWindow.tsx @@ -0,0 +1,63 @@ +import { useState, useCallback, useEffect } from 'react'; +import { backend } from '../../services/backend'; +import styles from './WelcomeWindow.module.css'; + +export function WelcomeWindow() { + const [dontShowAgain, setDontShowAgain] = useState(false); + const [isClosing, setIsClosing] = useState(false); + + const handleLetsGo = useCallback(async () => { + if (isClosing) return; + setIsClosing(true); + try { + await backend.closeWelcomeWindow(dontShowAgain); + } catch (error) { + console.error('Failed to close welcome window:', error); + setIsClosing(false); + } + }, [dontShowAgain, isClosing]); + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleLetsGo(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleLetsGo]); + + return ( +
+ PromptLight +
+ + + +
+
+ ); +} diff --git a/src/components/welcome/index.ts b/src/components/welcome/index.ts new file mode 100644 index 0000000..d6b8a9c --- /dev/null +++ b/src/components/welcome/index.ts @@ -0,0 +1 @@ +export { WelcomeWindow } from './WelcomeWindow'; diff --git a/src/services/backend/authTypes.ts b/src/services/backend/authTypes.ts index 3054372..b6c3868 100644 --- a/src/services/backend/authTypes.ts +++ b/src/services/backend/authTypes.ts @@ -31,6 +31,7 @@ export interface GeneralSettings { autoLaunch: boolean; hotkey: string | null; editorAlwaysOnTop: boolean; + welcomeScreenDismissed: boolean; } /** Cloud sync settings */ diff --git a/src/services/backend/mockAdapter.ts b/src/services/backend/mockAdapter.ts index bb72cac..e9052bd 100644 --- a/src/services/backend/mockAdapter.ts +++ b/src/services/backend/mockAdapter.ts @@ -33,6 +33,7 @@ const defaultSettings: AppSettings = { autoLaunch: false, hotkey: 'CommandOrControl+Shift+Space', editorAlwaysOnTop: true, + welcomeScreenDismissed: false, }, sync: { enabled: false, @@ -269,6 +270,13 @@ export class MockAdapter implements BackendAdapter { this.onOpenEditor?.(promptId, view); } + async closeWelcomeWindow(dontShowAgain: boolean): Promise { + if (dontShowAgain) { + this.settings.general.welcomeScreenDismissed = true; + } + this._actionHistory.push({ type: 'close_welcome', dontShowAgain }); + } + // ============ Clipboard Operations ============ async pasteAndDismiss(text: string): Promise { diff --git a/src/services/backend/tauriAdapter.ts b/src/services/backend/tauriAdapter.ts index 61c31fb..0e3dbf4 100644 --- a/src/services/backend/tauriAdapter.ts +++ b/src/services/backend/tauriAdapter.ts @@ -65,6 +65,10 @@ export class TauriAdapter implements BackendAdapter { return invoke('open_editor_window', { promptId, screenBounds, view }); } + async closeWelcomeWindow(dontShowAgain: boolean): Promise { + return invoke('close_welcome_window', { dontShowAgain }); + } + // ============ Clipboard Operations ============ async pasteAndDismiss(text: string): Promise { diff --git a/src/services/backend/types.ts b/src/services/backend/types.ts index e6cc89e..884409a 100644 --- a/src/services/backend/types.ts +++ b/src/services/backend/types.ts @@ -63,6 +63,9 @@ export interface BackendAdapter { view?: string ): Promise; + /** Close welcome window and optionally save dismissal preference */ + closeWelcomeWindow(dontShowAgain: boolean): Promise; + // ============ Clipboard Operations ============ /** Paste text to the previously focused app and dismiss window */ @@ -132,6 +135,7 @@ export type TestAction = | { type: 'paste_from_editor'; text: string } | { type: 'dismiss' } | { type: 'open_editor'; promptId: string | null; view?: string } + | { type: 'close_welcome'; dontShowAgain: boolean } | { type: 'copy_to_clipboard'; text: string } | { type: 'copy_as_file'; name: string; content: string } | { type: 'record_usage'; id: string } diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index dd7ebd9..a458221 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -15,6 +15,8 @@ export interface GeneralSettings { hotkey: string | null; /** Whether the editor window should float above other windows (default: true) */ editorAlwaysOnTop: boolean; + /** Whether the welcome screen has been dismissed (don't show again) */ + welcomeScreenDismissed: boolean; } /** Cloud sync settings */ @@ -79,6 +81,7 @@ const defaultSettings: AppSettings = { autoLaunch: false, hotkey: 'CommandOrControl+Shift+Space', editorAlwaysOnTop: true, + welcomeScreenDismissed: false, }, sync: { enabled: false, @@ -130,6 +133,7 @@ export const useSettingsStore = create((set, get) => ({ general: { ...settings.general, editorAlwaysOnTop: settings.general?.editorAlwaysOnTop ?? true, + welcomeScreenDismissed: settings.general?.welcomeScreenDismissed ?? false, }, appearance: { theme: settings.appearance?.theme ?? DEFAULT_THEME,