diff --git a/content/desktop.json b/content/desktop.json
index 75e426e..69f2838 100644
--- a/content/desktop.json
+++ b/content/desktop.json
@@ -29,7 +29,8 @@
{ "label": "fastfetch.sh", "icon": "ph:cpu-duotone", "open": { "terminal": "fastfetch" } },
{ "label": "whoami.sh", "icon": "ph:identification-card-duotone", "open": { "terminal": "whoami" } },
{ "label": "skills.sh", "icon": "ph:lightning-duotone", "open": { "terminal": "skills" } },
- { "label": "contact.sh", "icon": "ph:envelope-simple-duotone", "open": { "terminal": "contact" } }
+ { "label": "contact.sh", "icon": "ph:envelope-simple-duotone", "open": { "terminal": "contact" } },
+ { "label": "btop", "icon": "ph:chart-line-duotone", "open": { "app": "btop" } }
]
},
{
diff --git a/src/lib/btop/simulate.ts b/src/lib/btop/simulate.ts
new file mode 100644
index 0000000..47ef9a0
--- /dev/null
+++ b/src/lib/btop/simulate.ts
@@ -0,0 +1,26 @@
+export function randomWalk(value: number, step: number, minimum = 0, maximum = 100): number {
+ const next = value + (Math.random() * 2 - 1) * step;
+ return Math.min(maximum, Math.max(minimum, next));
+}
+
+export function usageTone(percent: number): string {
+ if (percent >= 80) return 'red';
+ if (percent >= 50) return 'yellow';
+ return 'green';
+}
+
+export function relativeTime(isoDate: string): string {
+ const elapsedMinutes = Math.floor((Date.now() - new Date(isoDate).getTime()) / 60000);
+ if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
+ const elapsedHours = Math.floor(elapsedMinutes / 60);
+ if (elapsedHours < 24) return `${elapsedHours}h`;
+ const elapsedDays = Math.floor(elapsedHours / 24);
+ if (elapsedDays < 60) return `${elapsedDays}d`;
+ return `${Math.floor(elapsedDays / 30)}mo`;
+}
+
+export function formatSize(sizeKb: number): string {
+ if (sizeKb >= 1024 * 1024) return `${(sizeKb / 1024 / 1024).toFixed(1)}G`;
+ if (sizeKb >= 1024) return `${(sizeKb / 1024).toFixed(1)}M`;
+ return `${sizeKb}K`;
+}
diff --git a/src/lib/btop/types.ts b/src/lib/btop/types.ts
new file mode 100644
index 0000000..fc73400
--- /dev/null
+++ b/src/lib/btop/types.ts
@@ -0,0 +1,35 @@
+export interface ContributionDay {
+ date: string;
+ level: number;
+ count: number;
+}
+
+export interface ContributionCalendar {
+ total: number;
+ weeks: ContributionDay[][];
+ currentStreak: number;
+ bestDay: ContributionDay | null;
+}
+
+export interface RepoProcess {
+ name: string;
+ url: string;
+ language: string | null;
+ stars: number;
+ forks: number;
+ sizeKb: number;
+ pushedAt: string;
+}
+
+export interface GithubSummary {
+ username: string;
+ profileUrl: string;
+ publicRepos: number;
+ followers: number;
+}
+
+export interface BtopData {
+ github: GithubSummary;
+ contributions: ContributionCalendar;
+ repos: RepoProcess[];
+}
diff --git a/src/lib/components/btop/BtopBox.svelte b/src/lib/components/btop/BtopBox.svelte
new file mode 100644
index 0000000..aaa75d4
--- /dev/null
+++ b/src/lib/components/btop/BtopBox.svelte
@@ -0,0 +1,46 @@
+
+
+
+ {number} {title}
+ {@render children()}
+
+
+
diff --git a/src/lib/components/btop/ContributionPanel.svelte b/src/lib/components/btop/ContributionPanel.svelte
new file mode 100644
index 0000000..a0e0d77
--- /dev/null
+++ b/src/lib/components/btop/ContributionPanel.svelte
@@ -0,0 +1,104 @@
+
+
+
+ {#if calendar && data}
+
+ {calendar.total.toLocaleString('en-US')} contributions in the last year
+ streak {calendar.currentStreak}d
+ {#if calendar.bestDay}
+ peak {calendar.bestDay.count} on {calendar.bestDay.date}
+ {/if}
+
+
+
+ {#each calendar.weeks as week, weekIndex (weekIndex)}
+
+ {#each week as day (day.date)}
+
+ {/each}
+
+ {/each}
+
+
+
+ less
+ {#each levelOpacity as opacity, level (level)}
+
+ {/each}
+ more
+
+ {:else}
+ connecting to github...
+ {/if}
+
+
+
diff --git a/src/lib/components/btop/CpuPanel.svelte b/src/lib/components/btop/CpuPanel.svelte
new file mode 100644
index 0000000..84d756f
--- /dev/null
+++ b/src/lib/components/btop/CpuPanel.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+ {#each history as value, index (index)}
+
+ {/each}
+
+
+
+ {#each cores as core (core.name)}
+
+ {core.name}
+
+ {Math.round(core.usage)}%
+
+ {/each}
+
+
+
+
diff --git a/src/lib/components/btop/MemoryPanel.svelte b/src/lib/components/btop/MemoryPanel.svelte
new file mode 100644
index 0000000..f344e71
--- /dev/null
+++ b/src/lib/components/btop/MemoryPanel.svelte
@@ -0,0 +1,105 @@
+
+
+
+
+ {#each meters as meter (meter.label)}
+
+
+ {meter.label}
+ {Math.round(meter.value)}%
+
+
+
+ {/each}
+
+
+ {#if disks.length}
+
+
disks
+ {#each disks as disk (disk.label)}
+
{disk.label} {disk.value}
+ {/each}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/btop/ProcessPanel.svelte b/src/lib/components/btop/ProcessPanel.svelte
new file mode 100644
index 0000000..24c25ce
--- /dev/null
+++ b/src/lib/components/btop/ProcessPanel.svelte
@@ -0,0 +1,99 @@
+
+
+
+ {#if data}
+
+
+
+
+ Pid
+ Program
+ Lang
+ Stars
+ Forks
+ Mem
+ Pushed
+
+
+
+ {#each data.repos as repo, index (repo.name)}
+
+ {firstProcessId + index}
+ {repo.name}
+ {repo.language ?? '-'}
+ {repo.stars}
+ {repo.forks}
+ {formatSize(repo.sizeKb)}
+ {relativeTime(repo.pushedAt)}
+
+ {/each}
+
+
+
+ {:else}
+ reading /proc...
+ {/if}
+
+
+
diff --git a/src/lib/components/btop/SystemMonitor.svelte b/src/lib/components/btop/SystemMonitor.svelte
new file mode 100644
index 0000000..42a38b7
--- /dev/null
+++ b/src/lib/components/btop/SystemMonitor.svelte
@@ -0,0 +1,112 @@
+
+
+
+
+ btop
+ github: {data?.github.username ?? '...'}
+ {clockLabel}
+
+
+
+
+
+
diff --git a/src/lib/components/desktop/Desktop.svelte b/src/lib/components/desktop/Desktop.svelte
index 2c1d490..8933664 100644
--- a/src/lib/components/desktop/Desktop.svelte
+++ b/src/lib/components/desktop/Desktop.svelte
@@ -6,6 +6,8 @@
import Terminal from '#lib/components/terminal/Terminal.svelte';
import ImageViewer from '#lib/components/viewer/ImageViewer.svelte';
import { imageFileName } from '#lib/desktop/imageViewer';
+ import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
+ import SystemMonitor from '../btop/SystemMonitor.svelte';
import DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte';
@@ -25,6 +27,9 @@
const isNarrow = $derived(areaWidth < narrowWidth);
const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.extraWindowIds]);
+ const floatingIds = $derived(isNarrow ? [] : openWindowIds.filter((id) => windowManager.isFloating(id)));
+ const tiledIds = $derived(openWindowIds.filter((id) => !floatingIds.includes(id)));
+ const area = $derived({ width: areaWidth, height: areaHeight });
const layout = $derived.by(() => {
const rects = new Map();
@@ -33,10 +38,16 @@
rects.set(soloId, fullscreenLayout());
return rects;
}
- dwindleLayout(openWindowIds.length, areaWidth, areaHeight).forEach((rect, index) => rects.set(openWindowIds[index], rect));
+ dwindleLayout(tiledIds.length, areaWidth, areaHeight).forEach((rect, index) => rects.set(tiledIds[index], rect));
return rects;
});
+ $effect(() => {
+ floatingIds.forEach((id, index) => {
+ if (!windowManager.floatingRects[id]) windowManager.setFloatingRect(id, defaultFloatingRect(area, index));
+ });
+ });
+
$effect(() => {
if (openWindowIds.length && !openWindowIds.includes(windowManager.focusedId)) {
windowManager.focus(openWindowIds.at(-1)!);
@@ -44,9 +55,31 @@
});
function styleFor(id: string): string {
+ const floatingRect = windowManager.floatingRects[id];
+ if (floatingIds.includes(id) && !layout.has(id)) {
+ return floatingRect ? floatingStyle(floatingRect, windowManager.focusedId === id) : 'display: none';
+ }
const rect = layout.get(id);
return rect ? tileStyle(rect, isNarrow ? 4 : 10) : 'display: none';
}
+
+ function updateFloatingRect(id: string, change: typeof moveRect, deltaX: number, deltaY: number) {
+ const rect = windowManager.floatingRects[id];
+ if (rect) windowManager.setFloatingRect(id, change(rect, deltaX, deltaY, area));
+ }
+
+ function windowProps(id: string) {
+ return {
+ focused: windowManager.focusedId === id,
+ tileStyle: styleFor(id),
+ floating: floatingIds.includes(id) && !layout.has(id),
+ canFloat: !isNarrow,
+ onfocus: () => windowManager.focus(id),
+ ontogglefloat: () => windowManager.toggleFloating(id),
+ ondrag: (deltaX: number, deltaY: number) => updateFloatingRect(id, moveRect, deltaX, deltaY),
+ onresize: (deltaX: number, deltaY: number) => updateFloatingRect(id, resizeRect, deltaX, deltaY)
+ };
+ }
@@ -66,29 +99,13 @@
{#if pageOpen}
- windowManager.focus(pageWindowId)}
- onclose={closePage}
- >
+
{@render children()}
{/if}
{#each windowManager.terminals as terminal (terminal.id)}
- windowManager.focus(terminal.id)}
- onclose={() => windowManager.closeWindow(terminal.id)}
- >
+ windowManager.closeWindow(terminal.id)}>
windowManager.focus(viewer.id)}
+ {...windowProps(viewer.id)}
onclose={() => windowManager.closeWindow(viewer.id)}
>
{/each}
+
+ {#each windowManager.systemMonitors as monitor (monitor.id)}
+ windowManager.closeWindow(monitor.id)}
+ >
+
+
+ {/each}
diff --git a/src/lib/components/desktop/Launcher.svelte b/src/lib/components/desktop/Launcher.svelte
index 25dc4be..aa57424 100644
--- a/src/lib/components/desktop/Launcher.svelte
+++ b/src/lib/components/desktop/Launcher.svelte
@@ -24,7 +24,7 @@
.filter((item) => !('page' in item.open))
.map((item) => ({
label: item.label,
- detail: 'terminal' in item.open ? `kitty ${item.open.terminal}`.trim() : 'url' in item.open ? item.open.url : '',
+ detail: launcherDetail(item.open),
icon: item.icon,
open: item.open
}));
@@ -66,6 +66,13 @@
selectedIndex = Math.max(0, selectedIndex - 1);
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]);
}
+
+ function launcherDetail(action: DesktopAction): string {
+ if ('terminal' in action) return `kitty ${action.terminal}`.trim();
+ if ('url' in action) return action.url;
+ if ('app' in action) return action.app;
+ return '';
+ }
diff --git a/src/lib/components/desktop/Window.svelte b/src/lib/components/desktop/Window.svelte
index 38447a4..b47ab21 100644
--- a/src/lib/components/desktop/Window.svelte
+++ b/src/lib/components/desktop/Window.svelte
@@ -9,8 +9,13 @@
focused,
tileStyle,
scrollKey = '',
+ floating = false,
+ canFloat = false,
onfocus,
onclose,
+ ontogglefloat,
+ ondrag,
+ onresize,
children
}: {
title: string;
@@ -19,12 +24,44 @@
focused: boolean;
tileStyle: string;
scrollKey?: string;
+ floating?: boolean;
+ canFloat?: boolean;
onfocus: () => void;
onclose: () => void;
+ ontogglefloat?: () => void;
+ ondrag?: (deltaX: number, deltaY: number) => void;
+ onresize?: (deltaX: number, deltaY: number) => void;
children: Snippet;
} = $props();
let contentElement = $state();
+ let isDragging = $state(false);
+
+ function trackPointer(event: PointerEvent, onmove?: (deltaX: number, deltaY: number) => void) {
+ if (!floating || !onmove || event.button !== 0) return;
+ if (event.target instanceof Element && event.target.closest('button')) return;
+ event.preventDefault();
+
+ let lastX = event.clientX;
+ let lastY = event.clientY;
+ isDragging = true;
+
+ const handleMove = (moveEvent: PointerEvent) => {
+ onmove(moveEvent.clientX - lastX, moveEvent.clientY - lastY);
+ lastX = moveEvent.clientX;
+ lastY = moveEvent.clientY;
+ };
+ const handleEnd = () => {
+ isDragging = false;
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', handleEnd);
+ window.removeEventListener('pointercancel', handleEnd);
+ };
+
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', handleEnd);
+ window.addEventListener('pointercancel', handleEnd);
+ }
$effect(() => {
scrollKey;
@@ -35,27 +72,49 @@
-
+ trackPointer(event, ondrag)}>
{title}
{#if path}
{path}
{/if}
-
-
-
+
+ {#if canFloat && ontogglefloat}
+
+
+
+ {/if}
+
+
+
+
{@render children()}
+ {#if floating && onresize}
+ trackPointer(event, onresize)}
+ >
+ {/if}