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.
This commit is contained in:
@@ -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<AppItem>) {
|
||||
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<AppItem> {
|
||||
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())
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
+15
-174
@@ -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<AppItem> {
|
||||
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<PathBuf, String> {
|
||||
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<Vec<String>, 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<String>) -> 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<String, String> {
|
||||
#[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(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub fn pinned_file(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
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<Vec<String>, 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<String>) -> 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())
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
@@ -19,7 +19,8 @@
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"center": true,
|
||||
"skipTaskbar": true
|
||||
"skipTaskbar": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+65
-529
@@ -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<AppItem[]>([]);
|
||||
const [pinnedPaths, setPinnedPaths] = createSignal<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [view, setView] = createSignal<View>("launcher");
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [iconCache, setIconCache] = createSignal<Record<string, string>>({});
|
||||
|
||||
const [dragIndex, setDragIndex] = createSignal<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
|
||||
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
let gridRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [loadedApps, pinned] = await Promise.all([
|
||||
invoke<AppItem[]>("get_apps"),
|
||||
invoke<string[]>("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<string>("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 (
|
||||
<div
|
||||
style={{
|
||||
...s.iconWrap,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={src()}
|
||||
fallback={
|
||||
<span style={{ ...s.iconFallback, "font-size": `${size * 0.38}px` }}>
|
||||
{letter()}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={src()}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
"object-fit": "contain",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.root} onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||
{/* ========== LAUNCHER ========== */}
|
||||
<Show when={view() === "launcher"}>
|
||||
<div style={s.header}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Пошук програм..."
|
||||
value={searchQuery()}
|
||||
onInput={handleInput}
|
||||
autofocus
|
||||
style={s.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={gridRef} style={s.grid} tabIndex={0}>
|
||||
<Show when={loading()}>
|
||||
<div style={s.empty}>Завантаження...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && error()}>
|
||||
<div style={s.empty}>Помилка: {error()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && !error() && filteredApps().length === 0}>
|
||||
<div style={s.empty}>
|
||||
{searchQuery().trim()
|
||||
? "Нічого не знайдено"
|
||||
: "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={filteredApps()}>
|
||||
{(app, index) => {
|
||||
const isPinned = () => pinnedPaths().includes(app.path);
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-index={index()}
|
||||
style={s.tile(isSelected(), isPinned())}
|
||||
onClick={() => launchApp(app.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
togglePin(app.path);
|
||||
}}
|
||||
title={app.name}
|
||||
>
|
||||
<Show when={isPinned()}>
|
||||
<span style={s.pinBadge}>★</span>
|
||||
</Show>
|
||||
<AppIcon path={app.path} name={app.name} />
|
||||
<div style={s.tileName}>{app.name}</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div style={s.footer}>
|
||||
<span>←→↑↓ · Enter · Ctrl+P пін · ПКМ пін</span>
|
||||
<span
|
||||
style={{ cursor: "pointer", color: "#777" }}
|
||||
onClick={() => setView("settings")}
|
||||
>
|
||||
Ctrl+, налаштування
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ========== SETTINGS ========== */}
|
||||
<Show when={view() === "settings"}>
|
||||
<div style={s.settingsHeader}>
|
||||
<button style={s.backBtn} onClick={() => setView("launcher")}>
|
||||
← Назад
|
||||
</button>
|
||||
<span style={{ "font-weight": 600, "font-size": "14px" }}>
|
||||
Закріплені
|
||||
</span>
|
||||
<div style={{ width: "64px" }} />
|
||||
</div>
|
||||
|
||||
<div style={s.sectionTitle}>
|
||||
Перетягни для зміни порядку · {pinnedApps().length}
|
||||
</div>
|
||||
|
||||
<ul style={s.settingsList}>
|
||||
<Show when={pinnedApps().length === 0}>
|
||||
<div style={s.empty}>
|
||||
Немає закріплених.
|
||||
{"\n"}
|
||||
У пошуку натисни Ctrl+P або ПКМ на плитці.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={pinnedApps()}>
|
||||
{(app, index) => {
|
||||
const isOver = () => dragOverIndex() === index();
|
||||
|
||||
return (
|
||||
<li
|
||||
draggable={true}
|
||||
style={s.settingsItem(isOver())}
|
||||
onDragStart={() => onDragStart(index())}
|
||||
onDragOver={(e) => onDragOver(e, index())}
|
||||
onDrop={() => onDrop(index())}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<span style={s.dragHandle}>⠿</span>
|
||||
<AppIcon path={app.path} name={app.name} size={28} />
|
||||
<div style={{ flex: 1, "min-width": 0 }}>
|
||||
<div style={s.settingsName}>{app.name}</div>
|
||||
<div style={s.settingsSource}>{app.source}</div>
|
||||
</div>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
style={{ ...s.iconBtn, color: "#c55" }}
|
||||
title="Видалити"
|
||||
onClick={() => removePinned(app.path)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
|
||||
<div style={s.footer}>
|
||||
<span>Esc — назад</span>
|
||||
</div>
|
||||
</Show>
|
||||
<LauncherView
|
||||
inputRef={(el) => (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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
...s.iconWrap,
|
||||
width: `${size()}px`,
|
||||
height: `${size()}px`,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={props.src}
|
||||
fallback={
|
||||
<span style={{ ...s.iconFallback, "font-size": `${size() * 0.38}px` }}>
|
||||
{letter()}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={props.src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: `${size()}px`,
|
||||
height: `${size()}px`,
|
||||
"object-fit": "contain",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-index={props.index}
|
||||
draggable={props.draggable}
|
||||
style={s.tile(
|
||||
props.isSelected,
|
||||
props.isPinned,
|
||||
props.isDragOver,
|
||||
props.isDragging
|
||||
)}
|
||||
onClick={() => 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}
|
||||
>
|
||||
<Show when={props.isPinned}>
|
||||
<span style={s.pinBadge}>★</span>
|
||||
</Show>
|
||||
<AppIcon src={props.iconSrc} name={props.app.name} />
|
||||
<div style={s.tileName}>{props.app.name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>;
|
||||
onInput: (e: Event) => void;
|
||||
loading: Accessor<boolean>;
|
||||
error: Accessor<string | null>;
|
||||
apps: Accessor<AppItem[]>;
|
||||
pinnedPaths: Accessor<string[]>;
|
||||
selectedIndex: Accessor<number>;
|
||||
iconCache: Accessor<Record<string, string>>;
|
||||
onLaunch: (path: string) => void;
|
||||
onSelect: (index: number) => void;
|
||||
onTogglePin: (path: string) => void;
|
||||
dragIndex: Accessor<number | null>;
|
||||
dragOverIndex: Accessor<number | null>;
|
||||
canDrag: Accessor<boolean>;
|
||||
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 (
|
||||
<>
|
||||
<div style={s.header}>
|
||||
<input
|
||||
ref={props.inputRef}
|
||||
type="text"
|
||||
placeholder="Пошук програм..."
|
||||
value={props.searchQuery()}
|
||||
onInput={props.onInput}
|
||||
autofocus
|
||||
style={s.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={props.gridRef} style={s.grid} tabIndex={0}>
|
||||
<Show when={props.loading()}>
|
||||
<div style={s.empty}>Завантаження...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.loading() && props.error()}>
|
||||
<div style={s.empty}>Помилка: {props.error()}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!props.loading() && !props.error() && props.apps().length === 0}
|
||||
>
|
||||
<div style={s.empty}>
|
||||
{props.searchQuery().trim()
|
||||
? "Нічого не знайдено"
|
||||
: "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={props.apps()}>
|
||||
{(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 (
|
||||
<AppTile
|
||||
app={app}
|
||||
index={index()}
|
||||
iconSrc={props.iconCache()[app.path]}
|
||||
isSelected={isSelected()}
|
||||
isPinned={isPinned()}
|
||||
isDragOver={isOver()}
|
||||
isDragging={isDragging()}
|
||||
draggable={props.canDrag()}
|
||||
onLaunch={props.onLaunch}
|
||||
onSelect={props.onSelect}
|
||||
onTogglePin={props.onTogglePin}
|
||||
onDragStart={props.onDragStart}
|
||||
onDragOver={props.onDragOver}
|
||||
onDrop={props.onDrop}
|
||||
onDragEnd={props.onDragEnd}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div style={s.footer}>
|
||||
<span>←→↑↓ · Enter · Ctrl+P / ПКМ пін</span>
|
||||
<span style={{ color: "#777" }}>
|
||||
Перетягування плиток для зміни порядку
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<AppItem[]>([]);
|
||||
const [pinnedPaths, setPinnedPaths] = createSignal<string[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [loadedApps, pinned] = await Promise.all([
|
||||
invoke<AppItem[]>("get_apps"),
|
||||
invoke<string[]>("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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createSignal, Accessor } from "solid-js";
|
||||
|
||||
export function useDragReorder(
|
||||
pinnedPaths: Accessor<string[]>,
|
||||
persistPins: (paths: string[]) => void,
|
||||
isSearchActive: Accessor<boolean>
|
||||
) {
|
||||
const [dragIndex, setDragIndex] = createSignal<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export function useIcons() {
|
||||
const [iconCache, setIconCache] = createSignal<Record<string, string>>({});
|
||||
|
||||
const loadIcon = async (path: string) => {
|
||||
if (iconCache()[path] !== undefined) return;
|
||||
try {
|
||||
const dataUrl = await invoke<string>("get_app_icon", { path });
|
||||
setIconCache((prev) => ({ ...prev, [path]: dataUrl || "" }));
|
||||
} catch {
|
||||
setIconCache((prev) => ({ ...prev, [path]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
iconCache,
|
||||
loadIcon,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createSignal, createMemo, Accessor } from "solid-js";
|
||||
import { AppItem } from "../types";
|
||||
|
||||
export function useSearch(
|
||||
apps: Accessor<AppItem[]>,
|
||||
pinnedApps: Accessor<AppItem[]>,
|
||||
pinnedPaths: Accessor<string[]>
|
||||
) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+149
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type AppItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export const COLS = 5;
|
||||
Reference in New Issue
Block a user