Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/stores/application/appLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ import Apps from 'gi://AstalApps';

import {searchApps} from '@/stores/application/applicationCatalog';

export interface AppLauncherState {
/** Reactive state used by the application launcher's search UI. */
export interface AppSearchTextState {
/** Current search text. */
text: Accessor<string>;
/** Updates the search text. */
setText: Setter<string>;
/** Index of the currently selected result. */
selectedIndex: Accessor<number>;
/** Updates the selected result index. */
setSelectedIndex: Setter<number>;
/** Applications matching the current search text. */
results: Accessor<Apps.Application[]>;
}

export function createAppLauncherState(): AppLauncherState {
export function createAppSearchTextState(): AppSearchTextState {
const [text, setText] = createState('');
const [selectedIndex, setSelectedIndex] = createState(0);
const results = createComputed(() => searchApps(text().trim().toLowerCase()));
Expand Down
24 changes: 22 additions & 2 deletions src/stores/application/applicationCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ function getResultKey(application: Apps.Application) {
return application.name + (application.description || '') + (application.iconName || '');
}

/**
* Filters the provided list of applications to ensure that only unique applications are returned,
* based on their name, description, and icon name.
* The results are limited to a maximum number of entries defined by MAX_APP_RESULTS.
* @param applicationList The list of applications to filter for uniqueness.
* @returns A list of unique applications, limited to MAX_APP_RESULTS.
*/
function getUniqueResults(applicationList: Apps.Application[]) {
const results: Apps.Application[] = [];
const seen = new Set<string>();
Expand All @@ -26,7 +33,7 @@ function getUniqueResults(applicationList: Apps.Application[]) {
return results;
}

function getApplicationList() {
function getSortedAppList() {
return applications.get_list().sort((applicationA, applicationB) => {
const scoreA = history.getScore(applicationA);
const scoreB = history.getScore(applicationB);
Expand All @@ -35,13 +42,26 @@ function getApplicationList() {
});
}

/**
* Records the launch of an application, updating its score in the history.
* @param application The application that was launched.
* @returns void
*/
export function recordAppLaunch(application: Apps.Application) {
history.recordLaunch(application);
}

/**
* Searches for applications based on the provided query string,
* returning a list of matching applications sorted by relevance and launch history.
* @param query The search query string.
* @returns A list of applications that match the search query.
*/
export function searchApps(query: string) {
const allApplications = getApplicationList();
const allApplications = getSortedAppList();

if (query === '') return getUniqueResults(allApplications);

const keywords = query.split(/\s+/);

const results = allApplications
Expand Down
23 changes: 23 additions & 0 deletions src/stores/application/applicationHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ function getApplicationKey(application: Apps.Application) {
return application.entry || application.executable || application.name;
}

/**
* Manages the history of application launches,
* allowing for scoring and ranking of applications based on launch frequency.
* The history is persisted to a JSON file in the user's state directory.
*/
export class ApplicationHistory {
private scores: Record<string, number> = {};

Expand All @@ -29,6 +34,11 @@ export class ApplicationHistory {
return this.scores[getApplicationKey(application)] || 0;
}

/**
* Records the launch of an application, updating its score in the history.
* @param application The application that was launched.
* @returns void
*/
recordLaunch(application: Apps.Application) {
const key = getApplicationKey(application);
if (!key) return;
Expand All @@ -41,6 +51,10 @@ export class ApplicationHistory {
this.save();
}

/**
* Trims the history to ensure that only the top MAX_HISTORY_ENTRIES are kept,
* based on their scores.
*/
private trim() {
const entries = Object.entries(this.scores)
.filter(([, score]) => Number.isFinite(score) && score > 0)
Expand All @@ -49,6 +63,12 @@ export class ApplicationHistory {
this.scores = Object.fromEntries(entries);
}

/**
* Migrates legacy scores from an older format to the current format,
* ensuring that only valid applications are retained.
* @param legacy A record of legacy application keys and their associated scores.
* @returns A new record of application keys and their migrated scores.
*/
private migrateLegacyScores(legacy: Record<string, number>) {
const migrated: Record<string, number> = {};
for (const [legacyKey, score] of Object.entries(legacy)) {
Expand All @@ -65,6 +85,9 @@ export class ApplicationHistory {
return migrated;
}

/**
* Loads the application launch history from the JSON file.
*/
private load() {
try {
const currentFile = Gio.File.new_for_path(HISTORY_FILE);
Expand Down
1 change: 1 addition & 0 deletions src/stores/wallpaper/wallpaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const wallpapersLoading = wallpapersLoadingState;
export const wallpaperApplying = wallpaperApplyingState;
export const wallpaperError = wallpaperErrorState;

// A new refresh invalidates every older async enumeration.
let refreshGeneration = 0;
let refreshCancellable: Gio.Cancellable | null = null;

Expand Down
2 changes: 2 additions & 0 deletions src/stores/wallpaper/wallpaperSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function createWallpaperSelectorState({

function animatePanelTo(height: number) {
targetPanelHeight = height;
// Reuse the current timer when the target changes during an animation.
if (animationTimer) return;

animationTimer = interval(ANIMATION_INTERVAL_MS, () => {
Expand All @@ -69,6 +70,7 @@ export function createWallpaperSelectorState({
deactivateSidePanel('wallpaper-selector', monitorConnector);

cancelHideTimer();
// Keep scanning and the window alive until the closing animation has settled.
hideTimer = timeout(HIDE_DELAY_MS, () => {
hideTimer = null;
setCoverFlowActive(false);
Expand Down
2 changes: 2 additions & 0 deletions src/stores/wallpaper/wallpaperThumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ function pumpThumbnailQueue() {
}

export function ensureWallpaperThumbnails(items: Wallpaper[], priority = true) {
// Visible cards use priority jobs; the initial scan fills the cache afterward.
const currentGeneration = generation;
for (const wallpaper of items) {
if (GLib.file_test(wallpaper.thumbnailPath, GLib.FileTest.IS_REGULAR)) continue;
Expand Down Expand Up @@ -162,6 +163,7 @@ export function subscribeThumbnailReady(subscriber: (path: string, thumbnailPath
}

export function cancelWallpaperThumbnailWork() {
// Invalidate queued and running jobs before killing their processes.
generation++;
thumbnailQueue = [];
queuedThumbnails.clear();
Expand Down
72 changes: 23 additions & 49 deletions src/widget/app-launcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,14 @@ import {Astal, Gdk, Gtk} from 'ags/gtk4';
import app from 'ags/gtk4/app';
import {type Timer, idle} from 'ags/time';

import {createAppLauncherState} from '@/stores/application/appLauncher';
import {
ensureLauncherBackground,
registerLauncherBackground,
} from '@/stores/application/launcherBackground';
import {AppList} from '@/widget/app-launcher/widget/AppList';
import {SearchInput} from '@/widget/app-launcher/widget/SearchInput';

function createLauncherBackground() {
const picture = new Gtk.Picture({
contentFit: Gtk.ContentFit.COVER,
canTarget: false,
canShrink: true,
hexpand: true,
vexpand: true,
halign: Gtk.Align.FILL,
valign: Gtk.Align.FILL,
widthRequest: 1,
heightRequest: 1,
});

const unregister = registerLauncherBackground(picture);
picture.connect('destroy', unregister);
import {createAppSearchTextState} from '@/stores/application/appLauncher';
import {ensureLauncherBackground} from '@/stores/application/launcherBackground';

return picture;
}
import {AppList} from './widget/AppList';
import {createLauncherBackground} from './widget/LauncherBackground';
import {SearchInputBar} from './widget/SearchInput';

function resetLauncherState(
function resetAppSearchTextState(
searchInput: Gtk.Entry,
setText: (text: string) => void,
setSelectedIndex: (index: number) => void
Expand Down Expand Up @@ -64,13 +44,15 @@ export interface AppLauncherProps {
}

export default function AppLauncher({monitor}: AppLauncherProps) {
const {text, setText, selectedIndex, setSelectedIndex, results} = createAppLauncherState();
const monitorConnector = monitor.get_connector();
let focusTimer: Timer | null = null;

const {text, setText, selectedIndex, setSelectedIndex, results} = createAppSearchTextState();

const launcherBackground = createLauncherBackground();

const searchInput = SearchInput({
let focusTimer: Timer | null = null;

const searchInputBar = SearchInputBar({
text,
setText,
selectedIndex,
Expand All @@ -79,17 +61,8 @@ export default function AppLauncher({monitor}: AppLauncherProps) {
monitorConnector,
});

const launcherContent = (
<box orientation={Gtk.Orientation.VERTICAL} vexpand>
<box vexpand />
<box class="applauncher-search-container" hexpand>
{searchInput}
</box>
</box>
) as Gtk.Box;

const appList = AppList({
text,
searchText: text,
selectedIndex,
results,
monitorConnector,
Expand All @@ -110,9 +83,9 @@ export default function AppLauncher({monitor}: AppLauncherProps) {
focusTimer = null;

if (!self.visible) {
resetLauncherState(searchInput, setText, setSelectedIndex);
resetAppSearchTextState(searchInputBar, setText, setSelectedIndex);
} else {
focusTimer = focusLauncher(searchInput, appList);
focusTimer = focusLauncher(searchInputBar, appList);
}
}}
>
Expand All @@ -125,14 +98,15 @@ export default function AppLauncher({monitor}: AppLauncherProps) {
vexpand
overflow={Gtk.Overflow.HIDDEN}
>
<overlay
hexpand
vexpand
$={(self: Gtk.Overlay) => {
self.set_child(launcherBackground);
self.add_overlay(launcherContent);
}}
/>
<overlay hexpand vexpand>
{launcherBackground}
<box $type="overlay" orientation={Gtk.Orientation.VERTICAL} vexpand>
<box vexpand />
<box class="applauncher-search-container" hexpand>
{searchInputBar}
</box>
</box>
</overlay>
</box>

{/* Right Panel */}
Expand Down
23 changes: 15 additions & 8 deletions src/widget/app-launcher/widget/AppItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import {recordAppLaunch} from '@/stores/application/applicationCatalog';
import {toggleAppLauncher} from '@/stores/shell/windowManager';

export interface AppItemProps {
res: Apps.Application;
app: Apps.Application;
monitorConnector: string | null;
}

/**
* Creates a set of properties for a Gtk.Image widget based on the provided icon string.
* If the icon string is a file path,
* it sets the 'file' property; otherwise, it sets the 'iconName' property.
* @param iconStr The icon string, either a file path or an icon name.
* @returns An object containing the appropriate Gtk.Image properties.
*/
function createImageProp(iconStr: string): Partial<Gtk.Image.ConstructorProps> {
const iconProps: Partial<Gtk.Image.ConstructorProps> = {
cssClasses: ['applauncher-item-icon'],
Expand All @@ -25,29 +32,29 @@ function createImageProp(iconStr: string): Partial<Gtk.Image.ConstructorProps> {
return iconProps;
}

export function AppItem({res, monitorConnector}: AppItemProps): Gtk.Button {
export function AppItem({app, monitorConnector}: AppItemProps): Gtk.Button {
return (
<button
class="applauncher-item"
canFocus={false}
onClicked={() => {
toggleAppLauncher(monitorConnector);
recordAppLaunch(res);
res.launch();
recordAppLaunch(app);
app.launch();
}}
>
<box orientation={Gtk.Orientation.HORIZONTAL} spacing={12}>
<image {...createImageProp(res.iconName)} />
<image {...createImageProp(app.iconName)} />
<box orientation={Gtk.Orientation.VERTICAL} valign={Gtk.Align.CENTER}>
<label
label={res.name}
label={app.name}
halign={Gtk.Align.START}
class="applauncher-item-name"
ellipsize={Pango.EllipsizeMode.END}
/>
{res.description && (
{app.description && (
<label
label={res.description}
label={app.description}
halign={Gtk.Align.START}
class="applauncher-item-desc"
ellipsize={Pango.EllipsizeMode.END}
Expand Down
Loading