From 9ebe68714344739c99086e55037e483a663c7e0f Mon Sep 17 00:00:00 2001 From: "vitalii.litvinchuk" Date: Sat, 19 Sep 2026 19:40:40 +0300 Subject: [PATCH] feat: Implement application launcher with pinned apps functionality - Added `AppItem` struct to represent application details. - Implemented `collect_lnk_one_level` function to gather .lnk files from specified directories. - Created `get_apps` command to retrieve applications from the Taskbar and Start Menu. - Developed `launch_app` command to open applications. - Introduced `get_app_icon` command to fetch application icons in base64 format. - Added functionality to manage pinned applications with `get_pinned_apps` and `set_pinned_apps` commands. - Implemented system tray with toggle window functionality. - Created UI components for displaying application icons and tiles. - Developed hooks for managing application state, drag-and-drop reordering, and search functionality. - Styled components for a cohesive user interface. --- src-tauri/src/apps.rs | 112 ++++++ src-tauri/src/icons.rs | 28 ++ src-tauri/src/main.rs | 189 +--------- src-tauri/src/pinned.rs | 26 ++ src-tauri/src/tray.rs | 54 +++ src-tauri/tauri.conf.json | 3 +- src/App.tsx | 594 ++++---------------------------- src/components/AppIcon.tsx | 43 +++ src/components/AppTile.tsx | 54 +++ src/components/LauncherView.tsx | 101 ++++++ src/hooks/useApps.ts | 76 ++++ src/hooks/useDragReorder.ts | 78 +++++ src/hooks/useIcons.ts | 21 ++ src/hooks/useSearch.ts | 58 ++++ src/styles.ts | 149 ++++++++ src/types.ts | 7 + 16 files changed, 889 insertions(+), 704 deletions(-) create mode 100644 src-tauri/src/apps.rs create mode 100644 src-tauri/src/icons.rs create mode 100644 src-tauri/src/pinned.rs create mode 100644 src-tauri/src/tray.rs create mode 100644 src/components/AppIcon.tsx create mode 100644 src/components/AppTile.tsx create mode 100644 src/components/LauncherView.tsx create mode 100644 src/hooks/useApps.ts create mode 100644 src/hooks/useDragReorder.ts create mode 100644 src/hooks/useIcons.ts create mode 100644 src/hooks/useSearch.ts create mode 100644 src/styles.ts create mode 100644 src/types.ts diff --git a/src-tauri/src/apps.rs b/src-tauri/src/apps.rs new file mode 100644 index 0000000..eca78a2 --- /dev/null +++ b/src-tauri/src/apps.rs @@ -0,0 +1,112 @@ +use serde::Serialize; +use std::path::Path; + +#[derive(Serialize, Clone)] +pub struct AppItem { + pub name: String, + pub path: String, + pub source: String, +} + +pub fn collect_lnk_one_level(dir: &Path, source: &str, apps: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // 1) .lnk прямо в цій папці + if path.is_file() + && path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("lnk")) + == Some(true) + { + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + apps.push(AppItem { + name, + path: path.to_string_lossy().to_string(), + source: source.to_string(), + }); + continue; + } + + // 2) один рівень глибше: Programs\SomeFolder\*.lnk + if path.is_dir() { + let Ok(sub_entries) = std::fs::read_dir(&path) else { + continue; + }; + for sub in sub_entries.flatten() { + let sub_path = sub.path(); + if sub_path.is_file() + && sub_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("lnk")) + == Some(true) + { + let name = sub_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + apps.push(AppItem { + name, + path: sub_path.to_string_lossy().to_string(), + source: source.to_string(), + }); + } + } + } + } +} + +#[tauri::command] +pub fn get_apps() -> Vec { + let mut apps = Vec::new(); + + if let Some(mut path) = dirs::data_dir() { + path.push(r"Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar"); + if let Ok(entries) = std::fs::read_dir(&path) { + for entry in entries.flatten() { + let file_path = entry.path(); + if file_path.extension().unwrap_or_default() == "lnk" { + let name = file_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + apps.push(AppItem { + name, + path: file_path.to_string_lossy().to_string(), + source: "Taskbar".to_string(), + }); + } + } + } + } + + if let Some(mut path) = dirs::data_dir() { + path.push(r"Microsoft\Windows\Start Menu\Programs"); + collect_lnk_one_level(&path, "Start Menu", &mut apps); + } + + if let Ok(program_data) = std::env::var("ProgramData") { + let path = + std::path::PathBuf::from(program_data).join(r"Microsoft\Windows\Start Menu\Programs"); + collect_lnk_one_level(&path, "Start Menu (All)", &mut apps); + } + + apps +} + +#[tauri::command] +pub fn launch_app(path: String) -> Result<(), String> { + open::that(path).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/icons.rs b/src-tauri/src/icons.rs new file mode 100644 index 0000000..366829b --- /dev/null +++ b/src-tauri/src/icons.rs @@ -0,0 +1,28 @@ +#[cfg(target_os = "windows")] +use windows_icons::{get_icon_base64_by_path_with_size, IconSize}; + +#[tauri::command] +pub fn get_app_icon(path: String) -> Result { + #[cfg(target_os = "windows")] + { + let result = get_icon_base64_by_path_with_size(&path, IconSize::Large) + .or_else(|_| windows_icons::get_icon_base64_by_path(&path)); + + match result { + Ok(b64) => { + if b64.starts_with("data:") { + Ok(b64) + } else { + Ok(format!("data:image/png;base64,{}", b64)) + } + } + Err(_) => Ok(String::new()), + } + } + + #[cfg(not(target_os = "windows"))] + { + let _ = path; + Ok(String::new()) + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1198e3e..4e80e88 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,129 +1,20 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::fs; -use std::path::PathBuf; -use tauri::AppHandle; -use tauri::{ - menu::{Menu, MenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - Manager, -}; +mod apps; +mod icons; +mod pinned; +mod tray; + use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; -use windows_icons::{get_icon_base64_by_path_with_size, IconSize}; - -#[derive(serde::Serialize)] -pub struct AppItem { - name: String, - path: String, - source: String, -} - -#[tauri::command] -fn get_apps() -> Vec { - let mut apps = Vec::new(); - - if let Some(mut path) = dirs::data_dir() { - path.push("Microsoft\\Internet Explorer\\Quick Launch\\User Pinned\\TaskBar"); - - if let Ok(entries) = std::fs::read_dir(&path) { - for entry in entries.flatten() { - let file_path = entry.path(); - if file_path.extension().unwrap_or_default() == "lnk" { - let name = file_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - apps.push(AppItem { - name, - path: file_path.to_string_lossy().to_string(), - source: "Taskbar".to_string(), - }); - } - } - } - } - - if let Some(mut path) = dirs::data_dir() { - path.push("Microsoft\\Windows\\Start Menu\\Programs"); - if let Ok(entries) = std::fs::read_dir(&path) { - for entry in entries.flatten() { - let file_path = entry.path(); - if file_path.extension().unwrap_or_default() == "lnk" { - let name = file_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - apps.push(AppItem { - name, - path: file_path.to_string_lossy().to_string(), - source: "Start Menu".to_string(), - }); - } - } - } - } - - apps -} - -#[tauri::command] -fn launch_app(path: String) -> Result<(), String> { - open::that(path).map_err(|e| e.to_string()) -} - -fn pinned_file(app: &AppHandle) -> Result { - let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - Ok(dir.join("pinned.json")) -} - -#[tauri::command] -fn get_pinned_apps(app: AppHandle) -> Result, String> { - let path = pinned_file(&app)?; - if !path.exists() { - return Ok(Vec::new()); - } - let data = fs::read_to_string(&path).map_err(|e| e.to_string())?; - serde_json::from_str(&data).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn set_pinned_apps(app: AppHandle, paths: Vec) -> Result<(), String> { - let path = pinned_file(&app)?; - let data = serde_json::to_string_pretty(&paths).map_err(|e| e.to_string())?; - fs::write(&path, data).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_app_icon(path: String) -> Result { - #[cfg(target_os = "windows")] - { - let result = get_icon_base64_by_path_with_size(&path, IconSize::Large) - .or_else(|_| windows_icons::get_icon_base64_by_path(&path)); - - match result { - Ok(b64) => { - if b64.starts_with("data:") { - Ok(b64) - } else { - Ok(format!("data:image/png;base64,{}", b64)) - } - } - Err(_) => Ok(String::new()), - } - } -} fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ - get_apps, - launch_app, - get_pinned_apps, - set_pinned_apps, - get_app_icon + apps::get_apps, + apps::launch_app, + pinned::get_pinned_apps, + pinned::set_pinned_apps, + icons::get_app_icon ]) .plugin( tauri_plugin_autostart::Builder::new() @@ -134,18 +25,10 @@ fn main() { .plugin( tauri_plugin_global_shortcut::Builder::new() .with_handler(|app, shortcut, event| { - if event.state == ShortcutState::Pressed { - if shortcut.matches(Modifiers::ALT, Code::Space) { - if let Some(window) = app.get_webview_window("main") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - window.hide().unwrap(); - } else { - window.show().unwrap(); - window.set_focus().unwrap(); - } - } - } + if event.state == ShortcutState::Pressed + && shortcut.matches(Modifiers::ALT, Code::Space) + { + tray::toggle_window(app); } }) .build(), @@ -154,49 +37,7 @@ fn main() { let alt_space = Shortcut::new(Some(Modifiers::ALT), Code::Space); app.global_shortcut().register(alt_space)?; - let show_i = MenuItem::with_id(app, "show", "Показати/Сховати", true, None::<&str>)?; - let quit_i = MenuItem::with_id(app, "quit", "Вийти", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show_i, &quit_i])?; - - TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .show_menu_on_left_click(false) - .on_tray_icon_event(|tray, event| match event { - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } => { - let app_handle = tray.app_handle(); - if let Some(window) = app_handle.get_webview_window("main") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - window.hide().unwrap(); - } else { - window.show().unwrap(); - window.set_focus().unwrap(); - } - } - } - _ => {} - }) - .on_menu_event(|app_handle, event| match event.id.as_ref() { - "quit" => std::process::exit(0), - "show" => { - if let Some(window) = app_handle.get_webview_window("main") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - window.hide().unwrap(); - } else { - window.show().unwrap(); - window.set_focus().unwrap(); - } - } - } - _ => {} - }) - .build(app)?; + tray::setup_tray(app)?; Ok(()) }) diff --git a/src-tauri/src/pinned.rs b/src-tauri/src/pinned.rs new file mode 100644 index 0000000..8a235b1 --- /dev/null +++ b/src-tauri/src/pinned.rs @@ -0,0 +1,26 @@ +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +pub fn pinned_file(app: &AppHandle) -> Result { + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join("pinned.json")) +} + +#[tauri::command] +pub fn get_pinned_apps(app: AppHandle) -> Result, String> { + let path = pinned_file(&app)?; + if !path.exists() { + return Ok(Vec::new()); + } + let data = fs::read_to_string(&path).map_err(|e| e.to_string())?; + serde_json::from_str(&data).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_pinned_apps(app: AppHandle, paths: Vec) -> Result<(), String> { + let path = pinned_file(&app)?; + let data = serde_json::to_string_pretty(&paths).map_err(|e| e.to_string())?; + fs::write(&path, data).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..8772595 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,54 @@ +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, +}; + +pub fn toggle_window(app_handle: &AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + let is_visible = window.is_visible().unwrap_or(false); + if is_visible { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } +} + +pub fn setup_tray(app: &tauri::App) -> Result<(), Box> { + let show_i = MenuItem::with_id(app, "show", "Показати/Сховати", true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "quit", "Вийти", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show_i, &quit_i])?; + + let default_icon = app + .default_window_icon() + .ok_or("default window icon missing")? + .clone(); + + TrayIconBuilder::new() + .icon(default_icon) + .menu(&menu) + .show_menu_on_left_click(false) + .on_tray_icon_event(|tray, event| match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } => { + let app_handle = tray.app_handle(); + toggle_window(app_handle); + } + _ => {} + }) + .on_menu_event(|app_handle, event| match event.id.as_ref() { + "quit" => std::process::exit(0), + "show" => { + toggle_window(app_handle); + } + _ => {} + }) + .build(app)?; + + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 97102c7..5de5e48 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,8 @@ "decorations": false, "transparent": true, "center": true, - "skipTaskbar": true + "skipTaskbar": true, + "dragDropEnabled": false } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index ba48809..77ebd44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,179 +1,65 @@ -import { - createSignal, - createMemo, - onMount, - Show, - For, - createEffect, -} from "solid-js"; -import { invoke } from "@tauri-apps/api/core"; +import { createEffect } from "solid-js"; import { getCurrentWindow } from "@tauri-apps/api/window"; - -type AppItem = { - name: string; - path: string; - source: string; -}; - -type View = "launcher" | "settings"; - -const COLS = 5; +import { COLS } from "./types"; +import { styles as s } from "./styles"; +import { useApps } from "./hooks/useApps"; +import { useSearch } from "./hooks/useSearch"; +import { useIcons } from "./hooks/useIcons"; +import { useDragReorder } from "./hooks/useDragReorder"; +import { LauncherView } from "./components/LauncherView"; function App() { - const [apps, setApps] = createSignal([]); - const [pinnedPaths, setPinnedPaths] = createSignal([]); - const [searchQuery, setSearchQuery] = createSignal(""); - const [selectedIndex, setSelectedIndex] = createSignal(0); - const [view, setView] = createSignal("launcher"); - const [loading, setLoading] = createSignal(true); - const [error, setError] = createSignal(null); - const [iconCache, setIconCache] = createSignal>({}); - - const [dragIndex, setDragIndex] = createSignal(null); - const [dragOverIndex, setDragOverIndex] = createSignal(null); - let inputRef: HTMLInputElement | undefined; let gridRef: HTMLDivElement | undefined; - onMount(async () => { - try { - const [loadedApps, pinned] = await Promise.all([ - invoke("get_apps"), - invoke("get_pinned_apps").catch(() => [] as string[]), - ]); - setApps(loadedApps); - setPinnedPaths(pinned); - } catch (e) { - setError(String(e)); - console.error(e); - } finally { - setLoading(false); - queueMicrotask(() => inputRef?.focus()); - } - }); + const { + apps, + pinnedPaths, + loading, + error, + pinnedApps, + persistPins, + togglePin, + launchApp, + } = useApps(() => inputRef?.focus()); - const loadIcon = async (path: string) => { - if (iconCache()[path] !== undefined) return; - try { - const dataUrl = await invoke("get_app_icon", { path }); - setIconCache((prev) => ({ ...prev, [path]: dataUrl || "" })); - } catch { - setIconCache((prev) => ({ ...prev, [path]: "" })); - } - }; + const { + searchQuery, + selectedIndex, + setSelectedIndex, + filteredApps, + moveSelection, + handleInput, + resetSearch, + } = useSearch(apps, pinnedApps, pinnedPaths); - const pinnedApps = createMemo(() => { - const order = pinnedPaths(); - const byPath = new Map(apps().map((a) => [a.path, a])); - return order.map((p) => byPath.get(p)).filter((a): a is AppItem => !!a); - }); + const { iconCache, loadIcon } = useIcons(); - const filteredApps = createMemo(() => { - const query = searchQuery().toLowerCase().trim(); - const all = apps(); - const pinnedSet = new Set(pinnedPaths()); + const isSearchActive = () => searchQuery().trim().length > 0; - if (!query) return pinnedApps(); - - const matched = all.filter((app) => - app.name.toLowerCase().includes(query) - ); - const pinnedMatched = matched.filter((a) => pinnedSet.has(a.path)); - const rest = matched.filter((a) => !pinnedSet.has(a.path)); - return [...pinnedMatched, ...rest]; - }); + const { + dragIndex, + dragOverIndex, + canDrag, + handleDragStart, + handleDragOver, + handleDrop, + handleDragEnd, + } = useDragReorder(pinnedPaths, persistPins, isSearchActive); createEffect(() => { - const list = view() === "settings" ? pinnedApps() : filteredApps(); - list.forEach((app) => loadIcon(app.path)); + filteredApps().forEach((app) => loadIcon(app.path)); }); - // Скрол до вибраного елемента createEffect(() => { const idx = selectedIndex(); - const el = gridRef?.querySelector(`[data-index="${idx}"]`) as HTMLElement | null; + const el = gridRef?.querySelector( + `[data-index="${idx}"]` + ) as HTMLElement | null; el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }); - const launchApp = async (path: string) => { - try { - await invoke("launch_app", { path }); - setSearchQuery(""); - setSelectedIndex(0); - await getCurrentWindow().hide(); - } catch (e) { - console.error("Помилка запуску:", e); - } - }; - - const persistPins = async (next: string[]) => { - const prev = pinnedPaths(); - setPinnedPaths(next); - try { - await invoke("set_pinned_apps", { paths: next }); - } catch (e) { - console.error(e); - setPinnedPaths(prev); - } - }; - - const togglePin = (path: string) => { - const current = pinnedPaths(); - const next = current.includes(path) - ? current.filter((p) => p !== path) - : [...current, path]; - persistPins(next); - }; - - const removePinned = (path: string) => { - persistPins(pinnedPaths().filter((p) => p !== path)); - }; - - const onDragStart = (index: number) => setDragIndex(index); - const onDragOver = (e: DragEvent, index: number) => { - e.preventDefault(); - setDragOverIndex(index); - }; - const onDrop = (index: number) => { - const from = dragIndex(); - if (from === null || from === index) { - setDragIndex(null); - setDragOverIndex(null); - return; - } - const list = [...pinnedPaths()]; - const [item] = list.splice(from, 1); - list.splice(index, 0, item); - persistPins(list); - setDragIndex(null); - setDragOverIndex(null); - }; - const onDragEnd = () => { - setDragIndex(null); - setDragOverIndex(null); - }; - - const moveSelection = (delta: number) => { - const len = filteredApps().length; - if (len === 0) return; - setSelectedIndex((i) => { - const next = i + delta; - if (next < 0) return 0; - if (next >= len) return len - 1; - return next; - }); - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (view() === "settings") { - if (e.key === "Escape") { - setView("launcher"); - setSelectedIndex(0); - queueMicrotask(() => inputRef?.focus()); - } - return; - } - const list = filteredApps(); const len = list.length; @@ -205,12 +91,9 @@ function App() { gridRef?.focus(); } else if (e.key === "Enter" && len > 0) { e.preventDefault(); - launchApp(list[selectedIndex()].path); + launchApp(list[selectedIndex()].path, resetSearch); } else if (e.key === "Escape") { getCurrentWindow().hide(); - } else if ((e.ctrlKey || e.metaKey) && e.key === ",") { - e.preventDefault(); - setView("settings"); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "p") { e.preventDefault(); const app = list[selectedIndex()]; @@ -226,377 +109,30 @@ function App() { } }; - const handleInput = (e: Event) => { - setSearchQuery((e.target as HTMLInputElement).value); - setSelectedIndex(0); - }; - - // ---------- стилі ---------- - const s = { - root: { - width: "100%", - height: "100vh", - background: "#1a1a1a", - color: "#e8e8e8", - "font-family": - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - display: "flex", - "flex-direction": "column", - overflow: "hidden", - "user-select": "none", - } as const, - header: { - padding: "12px 12px 8px", - "border-bottom": "1px solid #2e2e2e", - "flex-shrink": 0, - } as const, - input: { - width: "100%", - padding: "11px 14px", - "font-size": "16px", - "border-radius": "10px", - border: "1px solid #3a3a3a", - background: "#252525", - color: "#fff", - outline: "none", - "box-sizing": "border-box", - } as const, - grid: { - flex: 1, - "overflow-y": "auto", - padding: "12px", - display: "grid", - "grid-template-columns": `repeat(${COLS}, 1fr)`, - gap: "10px", - "align-content": "start", - outline: "none", - } as const, - tile: (selected: boolean, pinned: boolean) => - ({ - display: "flex", - "flex-direction": "column", - "align-items": "center", - "justify-content": "flex-start", - gap: "6px", - padding: "10px 6px 8px", - "border-radius": "12px", - cursor: "pointer", - background: selected ? "#2a4060" : "transparent", - border: selected - ? "1px solid #4a9eff" - : pinned - ? "1px solid #2a3a50" - : "1px solid transparent", - transition: "background 0.08s ease, border-color 0.08s ease", - "min-width": 0, - position: "relative", - }) as const, - iconWrap: { - width: "48px", - height: "48px", - "border-radius": "10px", - overflow: "hidden", - background: "#2a2a2a", - display: "flex", - "align-items": "center", - "justify-content": "center", - "flex-shrink": 0, - } as const, - iconImg: { - width: "48px", - height: "48px", - "object-fit": "contain", - } as const, - iconFallback: { - "font-size": "18px", - color: "#888", - "font-weight": 600, - } as const, - tileName: { - "font-size": "11px", - "font-weight": 500, - "text-align": "center", - "line-height": 1.25, - color: "#ccc", - width: "100%", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - "padding-left": "2px", - "padding-right": "2px", - } as const, - pinBadge: { - position: "absolute", - top: "4px", - right: "4px", - "font-size": "10px", - color: "#4a9eff", - "line-height": 1, - } as const, - empty: { - "grid-column": `1 / -1`, - padding: "40px 16px", - "text-align": "center", - color: "#666", - "font-size": "13px", - "line-height": 1.5, - "white-space": "pre-line", - } as const, - settingsHeader: { - display: "flex", - "align-items": "center", - "justify-content": "space-between", - padding: "12px 14px", - "border-bottom": "1px solid #2e2e2e", - "flex-shrink": 0, - } as const, - backBtn: { - background: "transparent", - border: "1px solid #444", - color: "#ccc", - padding: "5px 10px", - "border-radius": "6px", - cursor: "pointer", - "font-size": "12px", - } as const, - sectionTitle: { - padding: "10px 14px 4px", - "font-size": "11px", - color: "#666", - "text-transform": "uppercase", - "letter-spacing": "0.06em", - } as const, - settingsList: { - flex: 1, - "overflow-y": "auto", - padding: "8px", - margin: 0, - "list-style": "none", - } as const, - settingsItem: (isDragOver: boolean) => - ({ - display: "flex", - "align-items": "center", - gap: "10px", - padding: "8px 10px", - "border-radius": "8px", - background: isDragOver ? "#2a3a4a" : "transparent", - "border-left": "3px solid #4a9eff", - margin: "2px 0", - }) as const, - settingsName: { - flex: 1, - "min-width": 0, - "font-size": "13px", - "font-weight": 500, - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - } as const, - settingsSource: { - "font-size": "11px", - color: "#777", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - } as const, - iconBtn: { - background: "transparent", - border: "none", - color: "#666", - cursor: "pointer", - "font-size": "14px", - padding: "4px 6px", - "line-height": 1, - } as const, - footer: { - padding: "6px 12px", - "border-top": "1px solid #2e2e2e", - "font-size": "11px", - color: "#555", - display: "flex", - "justify-content": "space-between", - "align-items": "center", - "flex-shrink": 0, - } as const, - dragHandle: { - color: "#555", - cursor: "grab", - "font-size": "14px", - padding: "0 4px", - } as const, - }; - - const AppIcon = (props: { path: string; name: string; size?: number }) => { - const src = () => iconCache()[props.path]; - const letter = () => (props.name[0] || "?").toUpperCase(); - const size = props.size ?? 48; - - return ( -
- - {letter()} - - } - > - - -
- ); - }; - return (
- {/* ========== LAUNCHER ========== */} - -
- -
- -
- -
Завантаження...
-
- - -
Помилка: {error()}
-
- - -
- {searchQuery().trim() - ? "Нічого не знайдено" - : "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"} -
-
- - - {(app, index) => { - const isPinned = () => pinnedPaths().includes(app.path); - const isSelected = () => index() === selectedIndex(); - - return ( -
launchApp(app.path)} - onMouseEnter={() => setSelectedIndex(index())} - onContextMenu={(e) => { - e.preventDefault(); - togglePin(app.path); - }} - title={app.name} - > - - ★ - - -
{app.name}
-
- ); - }} -
-
- -
- ←→↑↓ · Enter · Ctrl+P пін · ПКМ пін - setView("settings")} - > - Ctrl+, налаштування - -
-
- - {/* ========== SETTINGS ========== */} - -
- - - Закріплені - -
-
- -
- Перетягни для зміни порядку · {pinnedApps().length} -
- -
    - -
    - Немає закріплених. - {"\n"} - У пошуку натисни Ctrl+P або ПКМ на плитці. -
    -
    - - - {(app, index) => { - const isOver = () => dragOverIndex() === index(); - - return ( -
  • onDragStart(index())} - onDragOver={(e) => onDragOver(e, index())} - onDrop={() => onDrop(index())} - onDragEnd={onDragEnd} - > - ⠿ - -
    -
    {app.name}
    -
    {app.source}
    -
    - -
  • - ); - }} -
    -
- -
- Esc — назад -
- + (inputRef = el)} + gridRef={(el) => (gridRef = el)} + searchQuery={searchQuery} + onInput={(e) => handleInput((e.target as HTMLInputElement).value)} + loading={loading} + error={error} + apps={filteredApps} + pinnedPaths={pinnedPaths} + selectedIndex={selectedIndex} + iconCache={iconCache} + onLaunch={(path) => launchApp(path, resetSearch)} + onSelect={setSelectedIndex} + onTogglePin={togglePin} + dragIndex={dragIndex} + dragOverIndex={dragOverIndex} + canDrag={canDrag} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + onDragEnd={handleDragEnd} + />
); } diff --git a/src/components/AppIcon.tsx b/src/components/AppIcon.tsx new file mode 100644 index 0000000..24c0416 --- /dev/null +++ b/src/components/AppIcon.tsx @@ -0,0 +1,43 @@ +import { Show } from "solid-js"; +import { styles as s } from "../styles"; + +type AppIconProps = { + src?: string; + name: string; + size?: number; +}; + +export function AppIcon(props: AppIconProps) { + const letter = () => (props.name[0] || "?").toUpperCase(); + const size = () => props.size ?? 48; + + return ( +
+ + {letter()} + + } + > + + +
+ ); +} diff --git a/src/components/AppTile.tsx b/src/components/AppTile.tsx new file mode 100644 index 0000000..8bfed05 --- /dev/null +++ b/src/components/AppTile.tsx @@ -0,0 +1,54 @@ +import { Show } from "solid-js"; +import { AppItem } from "../types"; +import { styles as s } from "../styles"; +import { AppIcon } from "./AppIcon"; + +type AppTileProps = { + app: AppItem; + index: number; + iconSrc?: string; + isSelected: boolean; + isPinned: boolean; + isDragOver: boolean; + isDragging: boolean; + draggable: boolean; + onLaunch: (path: string) => void; + onSelect: (index: number) => void; + onTogglePin: (path: string) => void; + onDragStart: (e: DragEvent, index: number) => void; + onDragOver: (e: DragEvent, index: number) => void; + onDrop: (e: DragEvent, index: number) => void; + onDragEnd: () => void; +}; + +export function AppTile(props: AppTileProps) { + return ( +
props.onLaunch(props.app.path)} + onMouseEnter={() => props.onSelect(props.index)} + onContextMenu={(e) => { + e.preventDefault(); + props.onTogglePin(props.app.path); + }} + onDragStart={(e) => props.onDragStart(e, props.index)} + onDragOver={(e) => props.onDragOver(e, props.index)} + onDrop={(e) => props.onDrop(e, props.index)} + onDragEnd={props.onDragEnd} + title={props.app.name} + > + + ★ + + +
{props.app.name}
+
+ ); +} diff --git a/src/components/LauncherView.tsx b/src/components/LauncherView.tsx new file mode 100644 index 0000000..8b3295e --- /dev/null +++ b/src/components/LauncherView.tsx @@ -0,0 +1,101 @@ +import { Accessor, For, Show } from "solid-js"; +import { AppItem } from "../types"; +import { styles as s } from "../styles"; +import { AppTile } from "./AppTile"; + +type LauncherViewProps = { + inputRef: (el: HTMLInputElement) => void; + gridRef: (el: HTMLDivElement) => void; + searchQuery: Accessor; + onInput: (e: Event) => void; + loading: Accessor; + error: Accessor; + apps: Accessor; + pinnedPaths: Accessor; + selectedIndex: Accessor; + iconCache: Accessor>; + onLaunch: (path: string) => void; + onSelect: (index: number) => void; + onTogglePin: (path: string) => void; + dragIndex: Accessor; + dragOverIndex: Accessor; + canDrag: Accessor; + onDragStart: (e: DragEvent, index: number) => void; + onDragOver: (e: DragEvent, index: number) => void; + onDrop: (e: DragEvent, index: number) => void; + onDragEnd: () => void; +}; + +export function LauncherView(props: LauncherViewProps) { + return ( + <> +
+ +
+ +
+ +
Завантаження...
+
+ + +
Помилка: {props.error()}
+
+ + +
+ {props.searchQuery().trim() + ? "Нічого не знайдено" + : "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"} +
+
+ + + {(app, index) => { + const isPinned = () => props.pinnedPaths().includes(app.path); + const isSelected = () => index() === props.selectedIndex(); + const isOver = () => props.dragOverIndex() === index(); + const isDragging = () => props.dragIndex() === index(); + + return ( + + ); + }} + +
+ +
+ ←→↑↓ · Enter · Ctrl+P / ПКМ пін + + Перетягування плиток для зміни порядку + +
+ + ); +} diff --git a/src/hooks/useApps.ts b/src/hooks/useApps.ts new file mode 100644 index 0000000..45715b3 --- /dev/null +++ b/src/hooks/useApps.ts @@ -0,0 +1,76 @@ +import { createSignal, createMemo, onMount } from "solid-js"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { AppItem } from "../types"; + +export function useApps(onInitialFocus?: () => void) { + const [apps, setApps] = createSignal([]); + const [pinnedPaths, setPinnedPaths] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + + onMount(async () => { + try { + const [loadedApps, pinned] = await Promise.all([ + invoke("get_apps"), + invoke("get_pinned_apps").catch(() => [] as string[]), + ]); + setApps(loadedApps); + setPinnedPaths(pinned); + } catch (e) { + setError(String(e)); + console.error(e); + } finally { + setLoading(false); + if (onInitialFocus) { + queueMicrotask(onInitialFocus); + } + } + }); + + const pinnedApps = createMemo(() => { + const order = pinnedPaths(); + const byPath = new Map(apps().map((a) => [a.path, a])); + return order.map((p) => byPath.get(p)).filter((a): a is AppItem => !!a); + }); + + const persistPins = async (next: string[]) => { + const prev = pinnedPaths(); + setPinnedPaths(next); + try { + await invoke("set_pinned_apps", { paths: next }); + } catch (e) { + console.error(e); + setPinnedPaths(prev); + } + }; + + const togglePin = (path: string) => { + const current = pinnedPaths(); + const next = current.includes(path) + ? current.filter((p) => p !== path) + : [...current, path]; + persistPins(next); + }; + + const launchApp = async (path: string, onLaunched?: () => void) => { + try { + await invoke("launch_app", { path }); + onLaunched?.(); + await getCurrentWindow().hide(); + } catch (e) { + console.error("Помилка запуску:", e); + } + }; + + return { + apps, + pinnedPaths, + loading, + error, + pinnedApps, + persistPins, + togglePin, + launchApp, + }; +} diff --git a/src/hooks/useDragReorder.ts b/src/hooks/useDragReorder.ts new file mode 100644 index 0000000..8ccefb3 --- /dev/null +++ b/src/hooks/useDragReorder.ts @@ -0,0 +1,78 @@ +import { createSignal, Accessor } from "solid-js"; + +export function useDragReorder( + pinnedPaths: Accessor, + persistPins: (paths: string[]) => void, + isSearchActive: Accessor +) { + const [dragIndex, setDragIndex] = createSignal(null); + const [dragOverIndex, setDragOverIndex] = createSignal(null); + + const canDrag = () => !isSearchActive(); + + const handleDragStart = (e: DragEvent, index: number) => { + if (!canDrag()) return; + setDragIndex(index); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + } + }; + + const handleDragOver = (e: DragEvent, index: number) => { + if (!canDrag()) return; + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "move"; + } + setDragOverIndex(index); + }; + + const handleDrop = (e: DragEvent, dropIndex: number) => { + if (!canDrag()) return; + e.preventDefault(); + + let from = dragIndex(); + if (from === null && e.dataTransfer) { + const data = e.dataTransfer.getData("text/plain"); + if (data !== "") { + const parsed = Number(data); + if (!Number.isNaN(parsed)) from = parsed; + } + } + + setDragIndex(null); + setDragOverIndex(null); + + if (from === null || from === dropIndex) return; + + const list = [...pinnedPaths()]; + if ( + from < 0 || + from >= list.length || + dropIndex < 0 || + dropIndex >= list.length + ) { + return; + } + + const [moved] = list.splice(from, 1); + list.splice(dropIndex, 0, moved); + persistPins(list); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDragOverIndex(null); + }; + + return { + dragIndex, + dragOverIndex, + canDrag, + handleDragStart, + handleDragOver, + handleDrop, + handleDragEnd, + }; +} diff --git a/src/hooks/useIcons.ts b/src/hooks/useIcons.ts new file mode 100644 index 0000000..a1207da --- /dev/null +++ b/src/hooks/useIcons.ts @@ -0,0 +1,21 @@ +import { createSignal } from "solid-js"; +import { invoke } from "@tauri-apps/api/core"; + +export function useIcons() { + const [iconCache, setIconCache] = createSignal>({}); + + const loadIcon = async (path: string) => { + if (iconCache()[path] !== undefined) return; + try { + const dataUrl = await invoke("get_app_icon", { path }); + setIconCache((prev) => ({ ...prev, [path]: dataUrl || "" })); + } catch { + setIconCache((prev) => ({ ...prev, [path]: "" })); + } + }; + + return { + iconCache, + loadIcon, + }; +} diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..d645631 --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,58 @@ +import { createSignal, createMemo, Accessor } from "solid-js"; +import { AppItem } from "../types"; + +export function useSearch( + apps: Accessor, + pinnedApps: Accessor, + pinnedPaths: Accessor +) { + const [searchQuery, setSearchQuery] = createSignal(""); + const [selectedIndex, setSelectedIndex] = createSignal(0); + + const filteredApps = createMemo(() => { + const query = searchQuery().toLowerCase().trim(); + const all = apps(); + const pinnedSet = new Set(pinnedPaths()); + + if (!query) return pinnedApps(); + + const matched = all.filter((app) => + app.name.toLowerCase().includes(query) + ); + const pinnedMatched = matched.filter((a) => pinnedSet.has(a.path)); + const rest = matched.filter((a) => !pinnedSet.has(a.path)); + return [...pinnedMatched, ...rest]; + }); + + const moveSelection = (delta: number) => { + const len = filteredApps().length; + if (len === 0) return; + setSelectedIndex((i) => { + const next = i + delta; + if (next < 0) return 0; + if (next >= len) return len - 1; + return next; + }); + }; + + const handleInput = (value: string) => { + setSearchQuery(value); + setSelectedIndex(0); + }; + + const resetSearch = () => { + setSearchQuery(""); + setSelectedIndex(0); + }; + + return { + searchQuery, + setSearchQuery, + selectedIndex, + setSelectedIndex, + filteredApps, + moveSelection, + handleInput, + resetSearch, + }; +} diff --git a/src/styles.ts b/src/styles.ts new file mode 100644 index 0000000..7242d96 --- /dev/null +++ b/src/styles.ts @@ -0,0 +1,149 @@ +import { COLS } from "./types"; + +export const styles = { + root: { + width: "100%", + height: "100vh", + background: "#1a1a1a", + color: "#e8e8e8", + "font-family": + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + display: "flex", + "flex-direction": "column", + overflow: "hidden", + "user-select": "none", + } as const, + + header: { + padding: "12px 12px 8px", + "border-bottom": "1px solid #2e2e2e", + "flex-shrink": 0, + } as const, + + input: { + width: "100%", + padding: "11px 14px", + "font-size": "16px", + "border-radius": "10px", + border: "1px solid #3a3a3a", + background: "#252525", + color: "#fff", + outline: "none", + "box-sizing": "border-box", + } as const, + + grid: { + flex: 1, + "overflow-y": "auto", + padding: "12px", + display: "grid", + "grid-template-columns": `repeat(${COLS}, 1fr)`, + gap: "10px", + "align-content": "start", + outline: "none", + } as const, + + tile: ( + selected: boolean, + pinned: boolean, + isDragOver: boolean = false, + isDragging: boolean = false + ) => + ({ + display: "flex", + "flex-direction": "column", + "align-items": "center", + "justify-content": "flex-start", + gap: "6px", + padding: "10px 6px 8px", + "border-radius": "12px", + cursor: "pointer", + background: isDragOver + ? "#2a4d6f" + : selected + ? "#2a4060" + : "transparent", + border: isDragOver + ? "1px dashed #6bb1ff" + : selected + ? "1px solid #4a9eff" + : pinned + ? "1px solid #2a3a50" + : "1px solid transparent", + opacity: isDragging ? 0.4 : 1, + transition: "background 0.08s ease, border-color 0.08s ease, opacity 0.08s ease", + "min-width": 0, + position: "relative", + }) as const, + + iconWrap: { + width: "48px", + height: "48px", + "border-radius": "10px", + overflow: "hidden", + background: "#2a2a2a", + display: "flex", + "align-items": "center", + "justify-content": "center", + "flex-shrink": 0, + "pointer-events": "none", + } as const, + + iconImg: { + width: "48px", + height: "48px", + "object-fit": "contain", + } as const, + + iconFallback: { + "font-size": "18px", + color: "#888", + "font-weight": 600, + } as const, + + tileName: { + "font-size": "11px", + "font-weight": 500, + "text-align": "center", + "line-height": 1.25, + color: "#ccc", + width: "100%", + overflow: "hidden", + "text-overflow": "ellipsis", + "white-space": "nowrap", + "padding-left": "2px", + "padding-right": "2px", + "pointer-events": "none", + } as const, + + pinBadge: { + position: "absolute", + top: "4px", + right: "4px", + "font-size": "10px", + color: "#4a9eff", + "line-height": 1, + "pointer-events": "none", + } as const, + + empty: { + "grid-column": `1 / -1`, + padding: "40px 16px", + "text-align": "center", + color: "#666", + "font-size": "13px", + "line-height": 1.5, + "white-space": "pre-line", + } as const, + + footer: { + padding: "6px 12px", + "border-top": "1px solid #2e2e2e", + "font-size": "11px", + color: "#555", + display: "flex", + "justify-content": "space-between", + "align-items": "center", + "flex-shrink": 0, + } as const, +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7c3f99d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +export type AppItem = { + name: string; + path: string; + source: string; +}; + +export const COLS = 5;