From 7f2c39469a7606be80e05f894832291d1677cdb5 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 18:16:38 -0500 Subject: [PATCH 01/41] wip --- package.json | 1 + pnpm-lock.yaml | 37 ++++ src/lib/app.svelte.ts | 6 + src/lib/components/Channel.svelte | 44 +++++ src/lib/components/split/SplitHeader.svelte | 68 ++++++++ src/lib/components/split/SplitNode.svelte | 35 ++++ src/lib/components/split/SplitView.svelte | 51 ++++++ src/lib/managers/split-manager.svelte.ts | 159 ++++++++++++++++++ src/lib/menus/channel-menu.ts | 78 ++++++++- src/lib/models/channel.svelte.ts | 8 +- .../(main)/channels/[username]/+page.svelte | 51 +++--- src/routes/settings/categories/advanced.ts | 2 +- 12 files changed, 508 insertions(+), 32 deletions(-) create mode 100644 src/lib/components/Channel.svelte create mode 100644 src/lib/components/split/SplitHeader.svelte create mode 100644 src/lib/components/split/SplitNode.svelte create mode 100644 src/lib/components/split/SplitView.svelte create mode 100644 src/lib/managers/split-manager.svelte.ts diff --git a/package.json b/package.json index 90c074a1..aae342fe 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "gql.tada": "^1.9.0", "graphql-web-lite": "16.6.0-4", "mode-watcher": "^1.1.0", + "paneforge": "^1.0.2", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", "tauri-plugin-cache-api": "^0.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee3495d9..0960c1cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.45.2) + paneforge: + specifier: ^1.0.2 + version: 1.0.2(svelte@5.45.2) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -2185,6 +2188,11 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + paneforge@1.0.2: + resolution: {integrity: sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==} + peerDependencies: + svelte: ^5.29.0 + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2443,6 +2451,11 @@ packages: peerDependencies: svelte: ^5.7.0 + runed@0.29.2: + resolution: {integrity: sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==} + peerDependencies: + svelte: ^5.7.0 + runed@0.35.1: resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} peerDependencies: @@ -2552,6 +2565,12 @@ packages: peerDependencies: svelte: ^5.0.0 + svelte-toolbelt@0.9.3: + resolution: {integrity: sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + svelte2tsx@0.7.45: resolution: {integrity: sha512-cSci+mYGygYBHIZLHlm/jYlEc1acjAHqaQaDFHdEBpUueM9kSTnPpvPtSl5VkJOU1qSJ7h1K+6F/LIUYiqC8VA==} peerDependencies: @@ -5032,6 +5051,12 @@ snapshots: package-manager-detector@1.6.0: {} + paneforge@1.0.2(svelte@5.45.2): + dependencies: + runed: 0.23.4(svelte@5.45.2) + svelte: 5.45.2 + svelte-toolbelt: 0.9.3(svelte@5.45.2) + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5226,6 +5251,11 @@ snapshots: esm-env: 1.2.2 svelte: 5.45.2 + runed@0.29.2(svelte@5.45.2): + dependencies: + esm-env: 1.2.2 + svelte: 5.45.2 + runed@0.35.1(@sveltejs/kit@2.49.0(@sveltejs/vite-plugin-svelte@6.2.1(rolldown-vite@7.2.8(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(yaml@2.8.2))(svelte@5.45.2))(rolldown-vite@7.2.8(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(yaml@2.8.2))(svelte@5.45.2))(svelte@5.45.2): dependencies: dequal: 2.0.3 @@ -5336,6 +5366,13 @@ snapshots: style-to-object: 1.0.14 svelte: 5.45.2 + svelte-toolbelt@0.9.3(svelte@5.45.2): + dependencies: + clsx: 2.1.1 + runed: 0.29.2(svelte@5.45.2) + style-to-object: 1.0.14 + svelte: 5.45.2 + svelte2tsx@0.7.45(svelte@5.45.2)(typescript@5.9.3): dependencies: dedent-js: 1.0.1 diff --git a/src/lib/app.svelte.ts b/src/lib/app.svelte.ts index d1d858d8..c74b0617 100644 --- a/src/lib/app.svelte.ts +++ b/src/lib/app.svelte.ts @@ -5,6 +5,7 @@ import { History } from "./history.svelte"; import { log } from "./log"; import { ChannelManager } from "./managers/channel-manager"; import { EmoteManager } from "./managers/emote-manager"; +import { SplitManager } from "./managers/split-manager.svelte"; import { TwitchClient } from "./twitch/client"; import type { EmoteSet } from "./emotes"; import type { Badge } from "./graphql/twitch"; @@ -40,6 +41,11 @@ class App { */ public readonly channels = new ChannelManager(this.twitch); + /** + * The current split layout. + */ + public readonly splits = new SplitManager(); + /** * Route history. */ diff --git a/src/lib/components/Channel.svelte b/src/lib/components/Channel.svelte new file mode 100644 index 00000000..98bd15a9 --- /dev/null +++ b/src/lib/components/Channel.svelte @@ -0,0 +1,44 @@ + + + + +
(app.focused = channel)}> + {#if channel.stream} + + {/if} + + + +
+ +
+
diff --git a/src/lib/components/split/SplitHeader.svelte b/src/lib/components/split/SplitHeader.svelte new file mode 100644 index 00000000..05f829f8 --- /dev/null +++ b/src/lib/components/split/SplitHeader.svelte @@ -0,0 +1,68 @@ + + +
+
+ + {channel.user.displayName} +
+ +
+ + + + + +
+
diff --git a/src/lib/components/split/SplitNode.svelte b/src/lib/components/split/SplitNode.svelte new file mode 100644 index 00000000..5d4d5938 --- /dev/null +++ b/src/lib/components/split/SplitNode.svelte @@ -0,0 +1,35 @@ + + +{#if typeof node === "string"} + +{:else} + + + + + + + + + + + +{/if} diff --git a/src/lib/components/split/SplitView.svelte b/src/lib/components/split/SplitView.svelte new file mode 100644 index 00000000..bf8a9f5c --- /dev/null +++ b/src/lib/components/split/SplitView.svelte @@ -0,0 +1,51 @@ + + +
+ {#if channel} + + {/if} + +
+ {#if channel} + + {:else} +
+ Empty Split +
+ {/if} + + {@render dropZone(dropCenter, "inset-0")} + {@render dropZone(dropUp, "top-0 inset-x-0 h-1/3")} + {@render dropZone(dropDown, "bottom-0 inset-x-0 h-1/3")} + {@render dropZone(dropLeft, "left-0 inset-y-0 w-1/3")} + {@render dropZone(dropRight, "right-0 inset-y-0 w-1/3")} +
+
+ +{#snippet dropZone(dropper: ReturnType, className: string)} +
+{/snippet} diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts new file mode 100644 index 00000000..fad1d865 --- /dev/null +++ b/src/lib/managers/split-manager.svelte.ts @@ -0,0 +1,159 @@ +import type { DragDropEvents } from "@dnd-kit-svelte/svelte"; +import type { PaneGroupProps } from "paneforge"; + +export type SplitDirection = "up" | "down" | "left" | "right"; + +export interface SplitParent { + direction: PaneGroupProps["direction"]; + first: SplitNode; + second: SplitNode; + size?: number; +} + +export type SplitNode = SplitParent | string; + +type SplitPath = "first" | "second"; + +export class SplitManager { + public root = $state(null); + + public constructor(root?: SplitNode) { + this.root = root ?? null; + } + + public insert(target: string, newNode: string, data: SplitParent) { + if (!this.root) { + this.root = target; + return; + } + + const path = this.#find(this.root, target); + if (!path) return; + + this.root = this.#update(this.root, path, (node) => { + if (typeof node === "string") { + return { + direction: data.direction, + first: node, + second: newNode, + size: 50, + }; + } + + return { ...data, size: 50 }; + }); + } + + public remove(target: string) { + if (!this.root) return; + + if (this.root === target) { + this.root = null; + return; + } + + const path = this.#find(this.root, target); + if (!path) return; + + const parentPath = path.slice(0, -1); + const sideToRemove = path.at(-1); + + if (!parentPath.length) { + if (typeof this.root === "string") return; + + const otherSide = sideToRemove === "first" ? "second" : "first"; + this.root = this.root[otherSide]; + + return; + } + + this.root = this.#replace(this.root, target); + } + + public handleDragEnd(event: Parameters[0]) { + const { source, target } = event.operation; + + if (source && target && source.id !== target.id) { + const sourceId = source.id.toString(); + const [targetId, position] = target.id.toString().split(":"); + + if (!position) return; + + this.remove(source.id.toString()); + + let direction: PaneGroupProps["direction"] = "horizontal"; + let first = targetId; + let second = sourceId; + + if (position === "up" || position === "down") { + direction = "vertical"; + } else if (position === "left" || position === "right") { + direction = "horizontal"; + } + + if (position === "up" || position === "left") { + first = sourceId; + second = targetId; + } else { + first = targetId; + second = sourceId; + } + + this.insert(targetId, sourceId, { direction, first, second }); + } + } + + #find(node: SplitNode, target: string, path: SplitPath[] = []): SplitPath[] | null { + if (typeof node === "string") { + return node === target ? path : null; + } + + const pathFirst = this.#find(node.first, target, [...path, "first"]); + if (pathFirst) return pathFirst; + + const pathSecond = this.#find(node.second, target, [...path, "second"]); + if (pathSecond) return pathSecond; + + return null; + } + + #update( + node: SplitNode, + path: SplitPath[], + updater: (node: SplitNode) => SplitNode, + ): SplitNode { + if (!path.length) { + return updater(node); + } + + if (typeof node === "string") { + throw new TypeError("Split path continues but node is a leaf"); + } + + const [side] = path; + + return { + ...node, + [side]: this.#update(node[side], path.slice(1), updater), + }; + } + + #replace(node: SplitNode, target: string): SplitNode { + if (typeof node === "string") { + if (node === target) { + throw new Error("Cannot remove root node"); + } + + return node; + } + + if (node.first === target) return node.second; + if (node.second === target) return node.first; + + return { + ...node, + first: this.#replace(node.first, target), + second: this.#replace(node.second, target), + }; + } +} diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index 1efea7ee..f23f5e20 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -1,9 +1,66 @@ import { CheckMenuItem, Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { goto } from "$app/navigation"; import { app } from "$lib/app.svelte"; +import type { SplitDirection, SplitParent } from "$lib/managers/split-manager.svelte"; import type { Channel } from "$lib/models/channel.svelte"; import { settings } from "$lib/settings"; +// function findSplit(splits: Split[], id: string): Split | undefined { +// for (const split of splits) { +// if (split.id === id) return split; +// const found = findSplit(split.splits, id); +// if (found) return found; +// } +// } + +// function removeSplit(splits: Split[], id: string): Split[] { +// return splits +// .filter((s) => s.id !== id) +// .map((s) => ({ ...s, splits: removeSplit(s.splits, id) })); +// } + +async function splitItem(channel: Channel, direction: SplitDirection) { + return MenuItem.new({ + id: `split-${direction}`, + text: `Split ${direction.charAt(0).toUpperCase() + direction.slice(1)}`, + enabled: !settings.state["advanced.singleConnection"], + async action() { + if (!channel.joined) { + await channel.join(true); + } + + if (!app.focused) return; + + const node: SplitParent = { + direction: direction === "up" || direction === "down" ? "vertical" : "horizontal", + first: channel.id, + second: app.focused.id, + }; + + if (direction === "down" || direction === "right") { + node.first = app.focused.id; + node.second = channel.id; + } + + app.splits.insert(app.focused.id, channel.id, node); + // // If we are splitting a channel that is already in a split, add to its splits + // if (app.focused && app.focused.id !== channel.id) { + // const parent = findSplit(settings.state.splits, app.focused.id); + + // if (parent) { + // parent.splits.push(split); + // settings.state.splits = [...settings.state.splits]; + + // return; + // } + // } + + // // Otherwise split the main view + // settings.state.splits.push(split); + }, + }); +} + export async function createChannelMenu(channel: Channel) { const separator = await PredefinedMenuItem.new({ item: "Separator", @@ -25,12 +82,19 @@ export async function createChannelMenu(channel: Channel) { async action() { await channel.leave(); + // settings.state.splits = removeSplit(settings.state.splits, channel.id); + if (app.focused === channel) { await goto("/"); } }, }); + const splitUp = await splitItem(channel, "up"); + const splitDown = await splitItem(channel, "down"); + const splitLeft = await splitItem(channel, "left"); + const splitRight = await splitItem(channel, "right"); + const pin = await CheckMenuItem.new({ id: "pin", text: "Pin", @@ -54,12 +118,24 @@ export async function createChannelMenu(channel: Channel) { enabled: channel.ephemeral, async action() { await channel.leave(); + // settings.state.splits = removeSplit(settings.state.splits, channel.id); app.channels.delete(channel.id); await goto("/"); }, }); return Menu.new({ - items: [join, leave, pin, separator, remove], + items: [ + join, + leave, + pin, + separator, + splitUp, + splitDown, + splitLeft, + splitRight, + separator, + remove, + ], }); } diff --git a/src/lib/models/channel.svelte.ts b/src/lib/models/channel.svelte.ts index 9836253f..5dd671bf 100644 --- a/src/lib/models/channel.svelte.ts +++ b/src/lib/models/channel.svelte.ts @@ -88,12 +88,14 @@ export class Channel { this.pinned = settings.state.pinned.includes(this.id); } - public async join() { + public async join(split = false) { app.channels.set(this.id, this); - app.focused = this; this.joined = true; - settings.state.lastJoined = this.ephemeral ? null : this.user.username; + if (!split) { + app.focused = this; + settings.state.lastJoined = this.ephemeral ? null : this.user.username; + } if (!this.viewers.has(this.id)) { const viewer = new Viewer(this, this.user); diff --git a/src/routes/(main)/channels/[username]/+page.svelte b/src/routes/(main)/channels/[username]/+page.svelte index c3eef33a..81203cb8 100644 --- a/src/routes/(main)/channels/[username]/+page.svelte +++ b/src/routes/(main)/channels/[username]/+page.svelte @@ -1,36 +1,33 @@ -
- {#if data.channel.stream} - - {/if} - - +
+ app.splits.handleDragEnd(event)}> + {#if app.splits.root} + + {:else} +
+ Empty Split +
+ {/if} -
- -
+ + {#snippet children(draggable)} +
+ {draggable.id} +
+ {/snippet} +
+
diff --git a/src/routes/settings/categories/advanced.ts b/src/routes/settings/categories/advanced.ts index 2130d35b..43fc9bb8 100644 --- a/src/routes/settings/categories/advanced.ts +++ b/src/routes/settings/categories/advanced.ts @@ -12,7 +12,7 @@ export default { type: "switch", label: "Only join one channel at a time", description: - "Limit the application to joining only one channel at a time to reduce resource usage. Enable this if you are experiencing performance issues.", + "Limit the application to joining only one channel at a time to reduce resource usage. Enable this if you are experiencing performance issues. Split view will be disabled when this is active.", }, { type: "group", From 413c4e0f4b8e3c26b349560e4e5f21603768ed0e Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 18:56:28 -0500 Subject: [PATCH 02/41] fix interactivity --- src/lib/components/split/SplitView.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/components/split/SplitView.svelte b/src/lib/components/split/SplitView.svelte index bf8a9f5c..65e94311 100644 --- a/src/lib/components/split/SplitView.svelte +++ b/src/lib/components/split/SplitView.svelte @@ -45,7 +45,11 @@ {#snippet dropZone(dropper: ReturnType, className: string)}
{/snippet} From 04d24ebc7cb47b19b7316830d96daeab40eda457 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 19:41:17 -0500 Subject: [PATCH 03/41] don't remove self --- src/lib/managers/split-manager.svelte.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts index fad1d865..460e6392 100644 --- a/src/lib/managers/split-manager.svelte.ts +++ b/src/lib/managers/split-manager.svelte.ts @@ -77,7 +77,9 @@ export class SplitManager { const sourceId = source.id.toString(); const [targetId, position] = target.id.toString().split(":"); - if (!position) return; + if (!position || sourceId === targetId) { + return; + } this.remove(source.id.toString()); From b0db0c39fd3a385e05a75e912404c2672d9f0804 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 19:41:40 -0500 Subject: [PATCH 04/41] move handle --- src/lib/components/split/SplitHeader.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/components/split/SplitHeader.svelte b/src/lib/components/split/SplitHeader.svelte index 05f829f8..82f3c370 100644 --- a/src/lib/components/split/SplitHeader.svelte +++ b/src/lib/components/split/SplitHeader.svelte @@ -1,6 +1,5 @@
-
- +
{channel.user.displayName}
From 347dfcfc3f38481496bcee39e648d0b5cefb08f1 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 19:41:55 -0500 Subject: [PATCH 05/41] move ref out --- src/lib/components/split/SplitView.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/split/SplitView.svelte b/src/lib/components/split/SplitView.svelte index 65e94311..25626e85 100644 --- a/src/lib/components/split/SplitView.svelte +++ b/src/lib/components/split/SplitView.svelte @@ -21,12 +21,12 @@ const channel = $derived(app.channels.get(id)); -
+
{#if channel} {/if} -
+
{#if channel} {:else} From bc87ddceb9d36d5af2c4a140d8e4167709cd7b87 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 20:20:38 -0500 Subject: [PATCH 06/41] fix inverted logic --- src/lib/managers/split-manager.svelte.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts index 460e6392..1c1592c0 100644 --- a/src/lib/managers/split-manager.svelte.ts +++ b/src/lib/managers/split-manager.svelte.ts @@ -32,15 +32,15 @@ export class SplitManager { this.root = this.#update(this.root, path, (node) => { if (typeof node === "string") { - return { - direction: data.direction, - first: node, - second: newNode, - size: 50, - }; + return { ...data, size: 50 }; } - return { ...data, size: 50 }; + return { + direction: data.direction, + first: node, + second: newNode, + size: 50, + }; }); } From 6620ffbb8cf75d4093de2b3179e79b4ea95f901c Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 20:20:58 -0500 Subject: [PATCH 07/41] remove old code early draft, forgot to delete --- src/lib/menus/channel-menu.ts | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index f23f5e20..e71066cc 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -5,20 +5,6 @@ import type { SplitDirection, SplitParent } from "$lib/managers/split-manager.sv import type { Channel } from "$lib/models/channel.svelte"; import { settings } from "$lib/settings"; -// function findSplit(splits: Split[], id: string): Split | undefined { -// for (const split of splits) { -// if (split.id === id) return split; -// const found = findSplit(split.splits, id); -// if (found) return found; -// } -// } - -// function removeSplit(splits: Split[], id: string): Split[] { -// return splits -// .filter((s) => s.id !== id) -// .map((s) => ({ ...s, splits: removeSplit(s.splits, id) })); -// } - async function splitItem(channel: Channel, direction: SplitDirection) { return MenuItem.new({ id: `split-${direction}`, @@ -43,20 +29,6 @@ async function splitItem(channel: Channel, direction: SplitDirection) { } app.splits.insert(app.focused.id, channel.id, node); - // // If we are splitting a channel that is already in a split, add to its splits - // if (app.focused && app.focused.id !== channel.id) { - // const parent = findSplit(settings.state.splits, app.focused.id); - - // if (parent) { - // parent.splits.push(split); - // settings.state.splits = [...settings.state.splits]; - - // return; - // } - // } - - // // Otherwise split the main view - // settings.state.splits.push(split); }, }); } @@ -82,8 +54,6 @@ export async function createChannelMenu(channel: Channel) { async action() { await channel.leave(); - // settings.state.splits = removeSplit(settings.state.splits, channel.id); - if (app.focused === channel) { await goto("/"); } @@ -118,7 +88,6 @@ export async function createChannelMenu(channel: Channel) { enabled: channel.ephemeral, async action() { await channel.leave(); - // settings.state.splits = removeSplit(settings.state.splits, channel.id); app.channels.delete(channel.id); await goto("/"); }, From 385d55e21c5ee908b8c9c5e1b02104390a172422 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Mon, 8 Dec 2025 23:16:56 -0500 Subject: [PATCH 08/41] channel list interactions --- src/lib/components/Channel.svelte | 4 ++ src/lib/components/ChannelList.svelte | 59 +++++++------------ src/lib/components/Draggable.svelte | 13 +++- src/lib/components/DraggableChannel.svelte | 29 +++++++++ src/lib/components/StreamTooltip.svelte | 1 + src/lib/components/split/SplitHeader.svelte | 27 ++++----- src/lib/components/split/SplitView.svelte | 17 +++--- src/lib/managers/split-manager.svelte.ts | 39 +++++++++--- src/routes/(main)/+layout.svelte | 43 ++++++++++---- .../(main)/channels/[username]/+page.svelte | 28 +++------ 10 files changed, 157 insertions(+), 103 deletions(-) create mode 100644 src/lib/components/DraggableChannel.svelte diff --git a/src/lib/components/Channel.svelte b/src/lib/components/Channel.svelte index 98bd15a9..7f681f4f 100644 --- a/src/lib/components/Channel.svelte +++ b/src/lib/components/Channel.svelte @@ -19,6 +19,10 @@ let unlisten: UnlistenFn | undefined; onMount(async () => { + if (!channel.joined) { + await channel.join(); + } + unlisten = await listen("recentmessages", async (event) => { for (const message of event.payload) { await handlers.get(message.type)?.handle(message); diff --git a/src/lib/components/ChannelList.svelte b/src/lib/components/ChannelList.svelte index c2fa7e91..90a7ae8b 100644 --- a/src/lib/components/ChannelList.svelte +++ b/src/lib/components/ChannelList.svelte @@ -1,16 +1,13 @@ - { - settings.state.pinned = move(settings.state.pinned, event); - }} -> - {#each groups as group} - {#if sidebar.collapsed} - - {:else} - - {group.type} - - {/if} +{#each groups as group} + {#if sidebar.collapsed} + + {:else} + + {group.type} + + {/if} - {#if group.type === "Pinned"} - - {#each group.channels as channel, i (channel.user.id)} - - {/each} - - {:else} - {#each group.channels as channel (channel.user.id)} -
- -
+ {#if group.type === "Pinned"} + + {#each group.channels as channel, i (channel.user.id)} + {/each} - {/if} - {/each} -
+ + {:else} + {#each group.channels as channel (channel.user.id)} +
+ +
+ {/each} + {/if} +{/each} diff --git a/src/lib/components/Draggable.svelte b/src/lib/components/Draggable.svelte index 089638b9..16d67770 100644 --- a/src/lib/components/Draggable.svelte +++ b/src/lib/components/Draggable.svelte @@ -1,13 +1,20 @@
diff --git a/src/lib/components/DraggableChannel.svelte b/src/lib/components/DraggableChannel.svelte new file mode 100644 index 00000000..17cf8e46 --- /dev/null +++ b/src/lib/components/DraggableChannel.svelte @@ -0,0 +1,29 @@ + + +
+
+ +
+ + {#if isDragging.current} +
+ +
+ {/if} +
diff --git a/src/lib/components/StreamTooltip.svelte b/src/lib/components/StreamTooltip.svelte index 2f4e8e61..92807524 100644 --- a/src/lib/components/StreamTooltip.svelte +++ b/src/lib/components/StreamTooltip.svelte @@ -24,6 +24,7 @@ diff --git a/src/lib/components/split/SplitHeader.svelte b/src/lib/components/split/SplitHeader.svelte index 82f3c370..a3663847 100644 --- a/src/lib/components/split/SplitHeader.svelte +++ b/src/lib/components/split/SplitHeader.svelte @@ -4,15 +4,16 @@ import SquareHalf from "~icons/ph/square-half-fill"; import X from "~icons/ph/x"; import { app } from "$lib/app.svelte"; - import type { Channel } from "$lib/models/channel.svelte"; import { Button } from "../ui/button"; interface Props { - channel: Channel; - handleRef: Attachment; + id: string; + handleRef?: Attachment; } - const { channel, handleRef }: Props = $props(); + const { id, handleRef }: Props = $props(); + + const channel = $derived(app.channels.get(id));
@@ -20,7 +21,9 @@ class="flex h-full flex-1 cursor-grab items-center gap-x-2 overflow-hidden px-1 active:cursor-grabbing" {@attach handleRef} > - {channel.user.displayName} + {#if channel} + {channel.user.displayName} + {/if}
@@ -29,11 +32,7 @@ size="icon-sm" variant="ghost" onclick={() => { - app.splits.insert(channel.id, `Window ${Date.now()}`, { - direction: "horizontal", - first: channel.id, - second: `Window ${Date.now()}`, - }); + app.splits.insertEmpty(id, "horizontal"); }} > @@ -44,11 +43,7 @@ size="icon-sm" variant="ghost" onclick={() => { - app.splits.insert(channel.id, `Window ${Date.now()}`, { - direction: "vertical", - first: channel.id, - second: `Window ${Date.now()}`, - }); + app.splits.insertEmpty(id, "vertical"); }} > @@ -59,7 +54,7 @@ size="icon-sm" variant="ghost" onclick={async () => { - app.splits.remove(channel.id); + app.splits.remove(id); await channel?.leave(); }} > diff --git a/src/lib/components/split/SplitView.svelte b/src/lib/components/split/SplitView.svelte index 25626e85..7df47480 100644 --- a/src/lib/components/split/SplitView.svelte +++ b/src/lib/components/split/SplitView.svelte @@ -22,9 +22,7 @@
- {#if channel} - - {/if} +
{#if channel} @@ -36,10 +34,13 @@ {/if} {@render dropZone(dropCenter, "inset-0")} - {@render dropZone(dropUp, "top-0 inset-x-0 h-1/3")} - {@render dropZone(dropDown, "bottom-0 inset-x-0 h-1/3")} - {@render dropZone(dropLeft, "left-0 inset-y-0 w-1/3")} - {@render dropZone(dropRight, "right-0 inset-y-0 w-1/3")} + + {#if channel} + {@render dropZone(dropUp, "top-0 inset-x-0 h-1/3")} + {@render dropZone(dropDown, "bottom-0 inset-x-0 h-1/3")} + {@render dropZone(dropLeft, "left-0 inset-y-0 w-1/3")} + {@render dropZone(dropRight, "right-0 inset-y-0 w-1/3")} + {/if}
@@ -48,7 +49,7 @@ class={[ "pointer-events-none absolute z-10", className, - dropper.isDropTarget.current && "bg-primary/50", + dropper.isDropTarget.current && "bg-primary/30", ]} {@attach dropper.ref} >
diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts index 1c1592c0..c8c0af5a 100644 --- a/src/lib/managers/split-manager.svelte.ts +++ b/src/lib/managers/split-manager.svelte.ts @@ -44,6 +44,16 @@ export class SplitManager { }); } + public insertEmpty(target: string, direction: PaneGroupProps["direction"]) { + const id = `split-${crypto.randomUUID()}`; + + this.insert(target, id, { + direction, + first: target, + second: id, + }); + } + public remove(target: string) { if (!this.root) return; @@ -70,6 +80,21 @@ export class SplitManager { this.root = this.#replace(this.root, target); } + public replace(target: string, replacement: string) { + if (!this.root || target === replacement) return; + + const path = this.#find(this.root, target); + if (!path) return; + + this.root = this.#update(this.root, path, (node) => { + if (typeof node === "string") { + return replacement; + } + + return node; + }); + } + public handleDragEnd(event: Parameters[0]) { const { source, target } = event.operation; @@ -77,28 +102,26 @@ export class SplitManager { const sourceId = source.id.toString(); const [targetId, position] = target.id.toString().split(":"); - if (!position || sourceId === targetId) { + if (sourceId === targetId) return; + + this.remove(sourceId); + + if (position === "center") { + this.replace(targetId, sourceId); return; } - this.remove(source.id.toString()); - let direction: PaneGroupProps["direction"] = "horizontal"; let first = targetId; let second = sourceId; if (position === "up" || position === "down") { direction = "vertical"; - } else if (position === "left" || position === "right") { - direction = "horizontal"; } if (position === "up" || position === "left") { first = sourceId; second = targetId; - } else { - first = targetId; - second = sourceId; } this.insert(targetId, sourceId, { direction, first, second }); diff --git a/src/routes/(main)/+layout.svelte b/src/routes/(main)/+layout.svelte index 66f5330f..e2bf9013 100644 --- a/src/routes/(main)/+layout.svelte +++ b/src/routes/(main)/+layout.svelte @@ -1,6 +1,10 @@ -
- app.splits.handleDragEnd(event)}> - {#if app.splits.root} - - {:else} -
- Empty Split -
- {/if} - - - {#snippet children(draggable)} -
- {draggable.id} -
- {/snippet} -
-
+
+ {#if app.splits.root} + + {:else} + + {/if}
From aecee69fa722b3745680d7b8957013fbaca0ef80 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 10:55:41 -0500 Subject: [PATCH 09/41] persist layout --- src/lib/managers/split-manager.svelte.ts | 9 ++++++--- src/lib/settings.ts | 3 +++ src/routes/(main)/+page.svelte | 6 +++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts index c8c0af5a..3e822db8 100644 --- a/src/lib/managers/split-manager.svelte.ts +++ b/src/lib/managers/split-manager.svelte.ts @@ -1,5 +1,6 @@ import type { DragDropEvents } from "@dnd-kit-svelte/svelte"; import type { PaneGroupProps } from "paneforge"; +import { settings } from "$lib/settings"; export type SplitDirection = "up" | "down" | "left" | "right"; @@ -15,10 +16,12 @@ export type SplitNode = SplitParent | string; type SplitPath = "first" | "second"; export class SplitManager { - public root = $state(null); + public get root() { + return settings.state.layout; + } - public constructor(root?: SplitNode) { - this.root = root ?? null; + public set root(value: SplitNode | null) { + settings.state.layout = value; } public insert(target: string, newNode: string, data: SplitParent) { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 6cdecc32..665275ed 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,5 +1,6 @@ import { RuneStore } from "@tauri-store/svelte"; import type { User } from "./graphql/twitch"; +import type { SplitNode } from "./managers/split-manager.svelte"; export type HighlightType = | "mention" @@ -69,6 +70,7 @@ interface Settings extends UserSettings { user: StoredUser | null; lastJoined: string | null; pinned: string[]; + layout: SplitNode | null; } export const defaultHighlightTypes: Record = { @@ -86,6 +88,7 @@ export const defaults: Settings = { user: null, lastJoined: null, pinned: [], + layout: null, "appearance.theme": "", "chat.hideScrollbar": false, diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index 37a1496e..a8fb4ece 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -18,7 +18,11 @@ await app.user.fetchEmoteSets(); } - if (settings.state.lastJoined) { + if (settings.state.layout) { + app.splits.root = settings.state.layout; + + await goto("/channels/split"); + } else if (settings.state.lastJoined) { await goto(`/channels/${settings.state.lastJoined}`); } From 0b48c3330f9594fc71c83c3d4a1fd9af0d2836cd Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 10:55:59 -0500 Subject: [PATCH 10/41] update routing --- src/lib/menus/channel-menu.ts | 7 +++++++ .../(main)/channels/[username]/+page.svelte | 16 ++-------------- src/routes/(main)/channels/split/+page.svelte | 13 +++++++++++++ 3 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 src/routes/(main)/channels/split/+page.svelte diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index e71066cc..5e435f42 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -1,5 +1,6 @@ import { CheckMenuItem, Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { goto } from "$app/navigation"; +import { page } from "$app/state"; import { app } from "$lib/app.svelte"; import type { SplitDirection, SplitParent } from "$lib/managers/split-manager.svelte"; import type { Channel } from "$lib/models/channel.svelte"; @@ -17,6 +18,8 @@ async function splitItem(channel: Channel, direction: SplitDirection) { if (!app.focused) return; + app.splits.root ??= app.focused.id; + const node: SplitParent = { direction: direction === "up" || direction === "down" ? "vertical" : "horizontal", first: channel.id, @@ -29,6 +32,10 @@ async function splitItem(channel: Channel, direction: SplitDirection) { } app.splits.insert(app.focused.id, channel.id, node); + + if (page.route.id !== "/(main)/channels/split") { + await goto("/channels/split"); + } }, }); } diff --git a/src/routes/(main)/channels/[username]/+page.svelte b/src/routes/(main)/channels/[username]/+page.svelte index ad764d81..bf3d0efa 100644 --- a/src/routes/(main)/channels/[username]/+page.svelte +++ b/src/routes/(main)/channels/[username]/+page.svelte @@ -1,19 +1,7 @@ -
- {#if app.splits.root} - - {:else} - - {/if} -
+ diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte new file mode 100644 index 00000000..518d8d04 --- /dev/null +++ b/src/routes/(main)/channels/split/+page.svelte @@ -0,0 +1,13 @@ + + +
+ {#if app.splits.root} + + {:else} + + {/if} +
From f30ecbb1de3ecbe715548539bc2ac25de307dbe5 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 12:38:52 -0500 Subject: [PATCH 11/41] more routing logic --- src/lib/components/split/SplitHeader.svelte | 25 ++++++++++++------- src/routes/(main)/+page.svelte | 3 ++- src/routes/(main)/channels/split/+page.svelte | 3 +++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/lib/components/split/SplitHeader.svelte b/src/lib/components/split/SplitHeader.svelte index a3663847..4e5e80ff 100644 --- a/src/lib/components/split/SplitHeader.svelte +++ b/src/lib/components/split/SplitHeader.svelte @@ -3,6 +3,7 @@ import SquareHalfBottom from "~icons/ph/square-half-bottom-fill"; import SquareHalf from "~icons/ph/square-half-fill"; import X from "~icons/ph/x"; + import { goto } from "$app/navigation"; import { app } from "$lib/app.svelte"; import { Button } from "../ui/button"; @@ -14,6 +15,20 @@ const { id, handleRef }: Props = $props(); const channel = $derived(app.channels.get(id)); + + async function closeSplit() { + app.splits.remove(id); + + if (!app.splits.root) { + if (channel) { + await goto(`/channels/${channel.user.username}`); + } else { + await goto("/"); + } + } else { + await channel?.leave(); + } + }
@@ -49,15 +64,7 @@ -
diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index a8fb4ece..5dae11a4 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -18,7 +18,8 @@ await app.user.fetchEmoteSets(); } - if (settings.state.layout) { + // Only navigate to split view if there's more than one split + if (settings.state.layout && typeof settings.state.layout !== "string") { app.splits.root = settings.state.layout; await goto("/channels/split"); diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 518d8d04..86af4593 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -2,6 +2,9 @@ import { app } from "$lib/app.svelte.js"; import SplitNode from "$lib/components/split/SplitNode.svelte"; import SplitView from "$lib/components/split/SplitView.svelte"; + import { settings } from "$lib/settings"; + + settings.state.lastJoined = null;
From 6b6bf1aad67b3fe01b7779af20d4171f74464316 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 12:48:54 -0500 Subject: [PATCH 12/41] refactors --- src/lib/components/Channel.svelte | 4 +--- src/lib/components/split/SplitNode.svelte | 6 ++---- src/lib/managers/split-manager.svelte.ts | 19 ++++++++++--------- src/lib/menus/channel-menu.ts | 10 ++++------ src/lib/models/channel.svelte.ts | 2 ++ 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/lib/components/Channel.svelte b/src/lib/components/Channel.svelte index 7f681f4f..d614757f 100644 --- a/src/lib/components/Channel.svelte +++ b/src/lib/components/Channel.svelte @@ -19,9 +19,7 @@ let unlisten: UnlistenFn | undefined; onMount(async () => { - if (!channel.joined) { - await channel.join(); - } + await channel.join(); unlisten = await listen("recentmessages", async (event) => { for (const message of event.payload) { diff --git a/src/lib/components/split/SplitNode.svelte b/src/lib/components/split/SplitNode.svelte index 5d4d5938..0811bca3 100644 --- a/src/lib/components/split/SplitNode.svelte +++ b/src/lib/components/split/SplitNode.svelte @@ -14,7 +14,7 @@ {#if typeof node === "string"} {:else} - + @@ -22,9 +22,7 @@ diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts index 3e822db8..fb7e078e 100644 --- a/src/lib/managers/split-manager.svelte.ts +++ b/src/lib/managers/split-manager.svelte.ts @@ -2,16 +2,17 @@ import type { DragDropEvents } from "@dnd-kit-svelte/svelte"; import type { PaneGroupProps } from "paneforge"; import { settings } from "$lib/settings"; +export type SplitAxis = PaneGroupProps["direction"]; export type SplitDirection = "up" | "down" | "left" | "right"; -export interface SplitParent { - direction: PaneGroupProps["direction"]; +export interface SplitBranch { + axis: SplitAxis; first: SplitNode; second: SplitNode; size?: number; } -export type SplitNode = SplitParent | string; +export type SplitNode = SplitBranch | string; type SplitPath = "first" | "second"; @@ -24,7 +25,7 @@ export class SplitManager { settings.state.layout = value; } - public insert(target: string, newNode: string, data: SplitParent) { + public insert(target: string, newNode: string, branch: SplitBranch) { if (!this.root) { this.root = target; return; @@ -35,11 +36,11 @@ export class SplitManager { this.root = this.#update(this.root, path, (node) => { if (typeof node === "string") { - return { ...data, size: 50 }; + return { ...branch, size: 50 }; } return { - direction: data.direction, + axis: branch.axis, first: node, second: newNode, size: 50, @@ -47,11 +48,11 @@ export class SplitManager { }); } - public insertEmpty(target: string, direction: PaneGroupProps["direction"]) { + public insertEmpty(target: string, axis: SplitAxis) { const id = `split-${crypto.randomUUID()}`; this.insert(target, id, { - direction, + axis, first: target, second: id, }); @@ -127,7 +128,7 @@ export class SplitManager { second = targetId; } - this.insert(targetId, sourceId, { direction, first, second }); + this.insert(targetId, sourceId, { axis: direction, first, second }); } } diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index 5e435f42..62db8760 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -2,7 +2,7 @@ import { CheckMenuItem, Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/a import { goto } from "$app/navigation"; import { page } from "$app/state"; import { app } from "$lib/app.svelte"; -import type { SplitDirection, SplitParent } from "$lib/managers/split-manager.svelte"; +import type { SplitBranch, SplitDirection } from "$lib/managers/split-manager.svelte"; import type { Channel } from "$lib/models/channel.svelte"; import { settings } from "$lib/settings"; @@ -12,16 +12,14 @@ async function splitItem(channel: Channel, direction: SplitDirection) { text: `Split ${direction.charAt(0).toUpperCase() + direction.slice(1)}`, enabled: !settings.state["advanced.singleConnection"], async action() { - if (!channel.joined) { - await channel.join(true); - } + await channel.join(true); if (!app.focused) return; app.splits.root ??= app.focused.id; - const node: SplitParent = { - direction: direction === "up" || direction === "down" ? "vertical" : "horizontal", + const node: SplitBranch = { + axis: direction === "up" || direction === "down" ? "vertical" : "horizontal", first: channel.id, second: app.focused.id, }; diff --git a/src/lib/models/channel.svelte.ts b/src/lib/models/channel.svelte.ts index 5dd671bf..34585036 100644 --- a/src/lib/models/channel.svelte.ts +++ b/src/lib/models/channel.svelte.ts @@ -89,6 +89,8 @@ export class Channel { } public async join(split = false) { + if (this.joined) return; + app.channels.set(this.id, this); this.joined = true; From 91b74fc603e19a85501642dbe6aed5bd36831b23 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 13:31:58 -0500 Subject: [PATCH 13/41] deduplicate draggable ids --- src/lib/components/Draggable.svelte | 2 +- src/lib/components/DraggableChannel.svelte | 2 +- src/lib/managers/split-manager.svelte.ts | 39 +++++++++------------- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/src/lib/components/Draggable.svelte b/src/lib/components/Draggable.svelte index 16d67770..cf983776 100644 --- a/src/lib/components/Draggable.svelte +++ b/src/lib/components/Draggable.svelte @@ -11,7 +11,7 @@ const { channel, index }: Props = $props(); const { ref, isDragging } = useSortable({ - id: () => channel.id, + id: () => `${channel.id}:channel-list/pinned`, type: "channel-list-item", index, }); diff --git a/src/lib/components/DraggableChannel.svelte b/src/lib/components/DraggableChannel.svelte index 17cf8e46..4730d297 100644 --- a/src/lib/components/DraggableChannel.svelte +++ b/src/lib/components/DraggableChannel.svelte @@ -11,7 +11,7 @@ const { channel }: Props = $props(); const { ref, isDragging } = useDraggable({ - id: () => channel.id, + id: () => `${channel.id}:channel-list`, type: "channel-list-item", }); diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts index fb7e078e..c2f6a2f0 100644 --- a/src/lib/managers/split-manager.svelte.ts +++ b/src/lib/managers/split-manager.svelte.ts @@ -101,35 +101,28 @@ export class SplitManager { public handleDragEnd(event: Parameters[0]) { const { source, target } = event.operation; + if (!source || !target || source.id === target.id) return; - if (source && target && source.id !== target.id) { - const sourceId = source.id.toString(); - const [targetId, position] = target.id.toString().split(":"); + const [sourceId] = source.id.toString().split(":"); + const [targetId, position] = target.id.toString().split(":"); - if (sourceId === targetId) return; + if (sourceId === targetId) return; - this.remove(sourceId); + this.remove(sourceId); - if (position === "center") { - this.replace(targetId, sourceId); - return; - } - - let direction: PaneGroupProps["direction"] = "horizontal"; - let first = targetId; - let second = sourceId; - - if (position === "up" || position === "down") { - direction = "vertical"; - } + if (position === "center") { + this.replace(targetId, sourceId); + return; + } - if (position === "up" || position === "left") { - first = sourceId; - second = targetId; - } + const isVertical = position === "up" || position === "down"; + const isFirst = position === "up" || position === "left"; - this.insert(targetId, sourceId, { axis: direction, first, second }); - } + this.insert(targetId, sourceId, { + axis: isVertical ? "vertical" : "horizontal", + first: isFirst ? sourceId : targetId, + second: isFirst ? targetId : sourceId, + }); } #find(node: SplitNode, target: string, path: SplitPath[] = []): SplitPath[] | null { From 36737b4b90df8d7dc0c120d7a1f2d04a1578cf0b Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 14:04:36 -0500 Subject: [PATCH 14/41] optimizations --- src/lib/components/split/SplitNode.svelte | 4 +- src/lib/managers/split-manager.svelte.ts | 109 ++++++++++------------ src/lib/menus/channel-menu.ts | 8 +- 3 files changed, 53 insertions(+), 68 deletions(-) diff --git a/src/lib/components/split/SplitNode.svelte b/src/lib/components/split/SplitNode.svelte index 0811bca3..7b047507 100644 --- a/src/lib/components/split/SplitNode.svelte +++ b/src/lib/components/split/SplitNode.svelte @@ -16,7 +16,7 @@ {:else} - + - + {/if} diff --git a/src/lib/managers/split-manager.svelte.ts b/src/lib/managers/split-manager.svelte.ts index c2f6a2f0..a1eb29a7 100644 --- a/src/lib/managers/split-manager.svelte.ts +++ b/src/lib/managers/split-manager.svelte.ts @@ -7,14 +7,14 @@ export type SplitDirection = "up" | "down" | "left" | "right"; export interface SplitBranch { axis: SplitAxis; - first: SplitNode; - second: SplitNode; size?: number; + before: SplitNode; + after: SplitNode; } export type SplitNode = SplitBranch | string; -type SplitPath = "first" | "second"; +type SplitPath = ("before" | "after")[]; export class SplitManager { public get root() { @@ -31,18 +31,15 @@ export class SplitManager { return; } - const path = this.#find(this.root, target); - if (!path) return; - - this.root = this.#update(this.root, path, (node) => { + this.#update(target, (node) => { if (typeof node === "string") { return { ...branch, size: 50 }; } return { axis: branch.axis, - first: node, - second: newNode, + before: node, + after: newNode, size: 50, }; }); @@ -53,8 +50,8 @@ export class SplitManager { this.insert(target, id, { axis, - first: target, - second: id, + before: target, + after: id, }); } @@ -66,37 +63,25 @@ export class SplitManager { return; } - const path = this.#find(this.root, target); - if (!path) return; - - const parentPath = path.slice(0, -1); - const sideToRemove = path.at(-1); - - if (!parentPath.length) { - if (typeof this.root === "string") return; - - const otherSide = sideToRemove === "first" ? "second" : "first"; - this.root = this.root[otherSide]; + if (typeof this.root !== "string") { + if (this.root.before === target) { + this.root = this.root.after; + return; + } - return; + if (this.root.after === target) { + this.root = this.root.before; + return; + } } - this.root = this.#replace(this.root, target); + this.root = this.#remove(this.root, target); } public replace(target: string, replacement: string) { if (!this.root || target === replacement) return; - const path = this.#find(this.root, target); - if (!path) return; - - this.root = this.#update(this.root, path, (node) => { - if (typeof node === "string") { - return replacement; - } - - return node; - }); + this.#update(target, () => replacement); } public handleDragEnd(event: Parameters[0]) { @@ -120,62 +105,62 @@ export class SplitManager { this.insert(targetId, sourceId, { axis: isVertical ? "vertical" : "horizontal", - first: isFirst ? sourceId : targetId, - second: isFirst ? targetId : sourceId, + before: isFirst ? sourceId : targetId, + after: isFirst ? targetId : sourceId, }); } - #find(node: SplitNode, target: string, path: SplitPath[] = []): SplitPath[] | null { + #find(node: SplitNode, target: string): SplitPath | null { if (typeof node === "string") { - return node === target ? path : null; + return node === target ? [] : null; } - const pathFirst = this.#find(node.first, target, [...path, "first"]); - if (pathFirst) return pathFirst; + const bPath = this.#find(node.before, target); + if (bPath) return ["before", ...bPath]; - const pathSecond = this.#find(node.second, target, [...path, "second"]); - if (pathSecond) return pathSecond; + const aPath = this.#find(node.after, target); + if (aPath) return ["after", ...aPath]; return null; } - #update( + #update(target: string, updater: (node: SplitNode) => SplitNode) { + const path = this.#find(this.root!, target); + + if (path) { + this.root = this.#applyUpdate(this.root!, path, updater); + } + } + + #applyUpdate( node: SplitNode, - path: SplitPath[], + path: SplitPath, updater: (node: SplitNode) => SplitNode, ): SplitNode { - if (!path.length) { - return updater(node); - } + if (!path.length) return updater(node); if (typeof node === "string") { - throw new TypeError("Split path continues but node is a leaf"); + throw new TypeError("Path continues but node is a leaf"); } - const [side] = path; + const [side, ...rest] = path; return { ...node, - [side]: this.#update(node[side], path.slice(1), updater), + [side]: this.#applyUpdate(node[side], rest, updater), }; } - #replace(node: SplitNode, target: string): SplitNode { - if (typeof node === "string") { - if (node === target) { - throw new Error("Cannot remove root node"); - } - - return node; - } + #remove(node: SplitNode, target: string): SplitNode { + if (typeof node === "string") return node; - if (node.first === target) return node.second; - if (node.second === target) return node.first; + if (node.before === target) return node.after; + if (node.after === target) return node.before; return { ...node, - first: this.#replace(node.first, target), - second: this.#replace(node.second, target), + before: this.#remove(node.before, target), + after: this.#remove(node.after, target), }; } } diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index 62db8760..8422e725 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -20,13 +20,13 @@ async function splitItem(channel: Channel, direction: SplitDirection) { const node: SplitBranch = { axis: direction === "up" || direction === "down" ? "vertical" : "horizontal", - first: channel.id, - second: app.focused.id, + before: channel.id, + after: app.focused.id, }; if (direction === "down" || direction === "right") { - node.first = app.focused.id; - node.second = channel.id; + node.before = app.focused.id; + node.after = channel.id; } app.splits.insert(app.focused.id, channel.id, node); From a039bc810bee64cd5c6e43988d7269d982cd7dbd Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 14:07:58 -0500 Subject: [PATCH 15/41] naming --- src/lib/app.svelte.ts | 4 ++-- src/lib/components/split/SplitNode.svelte | 2 +- src/lib/menus/channel-menu.ts | 2 +- src/lib/settings.ts | 2 +- src/lib/{managers/split-manager.svelte.ts => split-layout.ts} | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename src/lib/{managers/split-manager.svelte.ts => split-layout.ts} (99%) diff --git a/src/lib/app.svelte.ts b/src/lib/app.svelte.ts index c74b0617..b3f34fb1 100644 --- a/src/lib/app.svelte.ts +++ b/src/lib/app.svelte.ts @@ -5,7 +5,7 @@ import { History } from "./history.svelte"; import { log } from "./log"; import { ChannelManager } from "./managers/channel-manager"; import { EmoteManager } from "./managers/emote-manager"; -import { SplitManager } from "./managers/split-manager.svelte"; +import { SplitLayout } from "./split-layout"; import { TwitchClient } from "./twitch/client"; import type { EmoteSet } from "./emotes"; import type { Badge } from "./graphql/twitch"; @@ -44,7 +44,7 @@ class App { /** * The current split layout. */ - public readonly splits = new SplitManager(); + public readonly splits = new SplitLayout(); /** * Route history. diff --git a/src/lib/components/split/SplitNode.svelte b/src/lib/components/split/SplitNode.svelte index 7b047507..0156218f 100644 --- a/src/lib/components/split/SplitNode.svelte +++ b/src/lib/components/split/SplitNode.svelte @@ -1,6 +1,6 @@ From 6aa9516528949ff54bb47ecf14598fdb9ca0ebc3 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 19:23:30 -0500 Subject: [PATCH 18/41] update styles --- src/lib/components/split/SplitNode.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/split/SplitNode.svelte b/src/lib/components/split/SplitNode.svelte index 0156218f..50ad068c 100644 --- a/src/lib/components/split/SplitNode.svelte +++ b/src/lib/components/split/SplitNode.svelte @@ -22,7 +22,7 @@ From b90db2d442c32aa83cad5d50054154c514684e45 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 19:27:44 -0500 Subject: [PATCH 19/41] don't save single splits --- src/lib/components/split/SplitHeader.svelte | 2 +- src/lib/settings.ts | 12 +++++++++++- src/routes/(main)/+page.svelte | 3 +-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/lib/components/split/SplitHeader.svelte b/src/lib/components/split/SplitHeader.svelte index 2dfdcfeb..eab788ca 100644 --- a/src/lib/components/split/SplitHeader.svelte +++ b/src/lib/components/split/SplitHeader.svelte @@ -20,11 +20,11 @@ if (app.splits.root === id) { if (channel) { await goto(`/channels/${channel.user.username}`); - app.splits.root = null; } else { await goto("/"); } + app.splits.root = null; return; } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 2829a5f5..6858b7b0 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -117,4 +117,14 @@ export const defaults: Settings = { "advanced.logs.level": "info", }; -export const settings = new RuneStore("settings", defaults); +export const settings = new RuneStore("settings", defaults, { + hooks: { + beforeBackendSync(state) { + if (typeof state.layout === "string") { + state.layout = null; + } + + return state; + }, + }, +}); diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index 5dae11a4..a8fb4ece 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -18,8 +18,7 @@ await app.user.fetchEmoteSets(); } - // Only navigate to split view if there's more than one split - if (settings.state.layout && typeof settings.state.layout !== "string") { + if (settings.state.layout) { app.splits.root = settings.state.layout; await goto("/channels/split"); From 713193e888bd938cf8c8eb70241b9eed8e42b4c1 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 19:36:58 -0500 Subject: [PATCH 20/41] update --- src/lib/components/StreamInfo.svelte | 2 +- src/lib/components/split/SplitHeader.svelte | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/components/StreamInfo.svelte b/src/lib/components/StreamInfo.svelte index 4fb763ad..541f09f0 100644 --- a/src/lib/components/StreamInfo.svelte +++ b/src/lib/components/StreamInfo.svelte @@ -38,7 +38,7 @@

{stream.title}

diff --git a/src/lib/components/split/SplitHeader.svelte b/src/lib/components/split/SplitHeader.svelte index eab788ca..37c750d2 100644 --- a/src/lib/components/split/SplitHeader.svelte +++ b/src/lib/components/split/SplitHeader.svelte @@ -33,12 +33,21 @@ } -
+
{#if channel} + {channel.user.displayName} + {channel.user.displayName} {/if}
From 0fc4c86e10901265a7053be98183a099a4224c4b Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 19:49:12 -0500 Subject: [PATCH 21/41] move layout to separate store --- src/hooks.client.ts | 4 ---- src/lib/menus/channel-menu.ts | 4 +++- src/lib/settings.ts | 15 +-------------- src/lib/split-layout.ts | 9 ++++----- src/lib/stores.ts | 24 ++++++++++++++++++++++++ src/routes/(main)/+page.svelte | 5 +++-- 6 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 src/lib/stores.ts diff --git a/src/hooks.client.ts b/src/hooks.client.ts index e4671fe5..25f4122b 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,14 +1,10 @@ import { stats } from "tauri-plugin-cache-api"; import { log } from "$lib/log"; -import { settings } from "$lib/settings"; import { loadThemes } from "$lib/themes"; export async function init() { const { totalSize } = await stats(); log.info(`Cache has ${totalSize} items`); - await settings.start(); - log.info("Settings synced"); - await loadThemes(); } diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index fa459b4d..3fa3f407 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -4,7 +4,9 @@ import { page } from "$app/state"; import { app } from "$lib/app.svelte"; import type { Channel } from "$lib/models/channel.svelte"; import { settings } from "$lib/settings"; -import type { SplitBranch, SplitDirection } from "$lib/split-layout"; +import type { SplitBranch } from "$lib/split-layout"; + +type SplitDirection = "up" | "down" | "left" | "right"; async function splitItem(channel: Channel, direction: SplitDirection) { return MenuItem.new({ diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 6858b7b0..032b4672 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,6 +1,5 @@ import { RuneStore } from "@tauri-store/svelte"; import type { User } from "./graphql/twitch"; -import type { SplitNode } from "./split-layout"; export type HighlightType = | "mention" @@ -70,7 +69,6 @@ interface Settings extends UserSettings { user: StoredUser | null; lastJoined: string | null; pinned: string[]; - layout: SplitNode | null; } export const defaultHighlightTypes: Record = { @@ -88,7 +86,6 @@ export const defaults: Settings = { user: null, lastJoined: null, pinned: [], - layout: null, "appearance.theme": "", "chat.hideScrollbar": false, @@ -117,14 +114,4 @@ export const defaults: Settings = { "advanced.logs.level": "info", }; -export const settings = new RuneStore("settings", defaults, { - hooks: { - beforeBackendSync(state) { - if (typeof state.layout === "string") { - state.layout = null; - } - - return state; - }, - }, -}); +export const settings = new RuneStore("settings", defaults, { autoStart: true }); diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index d2dcdbad..ca8d0d01 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -1,9 +1,8 @@ import type { DragDropEvents } from "@dnd-kit-svelte/svelte"; import type { PaneGroupProps } from "paneforge"; -import { settings } from "$lib/settings"; +import { layout } from "./stores"; -export type SplitAxis = PaneGroupProps["direction"]; -export type SplitDirection = "up" | "down" | "left" | "right"; +type SplitAxis = PaneGroupProps["direction"]; export interface SplitBranch { axis: SplitAxis; @@ -18,11 +17,11 @@ type SplitPath = ("before" | "after")[]; export class SplitLayout { public get root() { - return settings.state.layout; + return layout.state.root; } public set root(value: SplitNode | null) { - settings.state.layout = value; + layout.state.root = value; } public insert(target: string, newNode: string, branch: SplitBranch) { diff --git a/src/lib/stores.ts b/src/lib/stores.ts new file mode 100644 index 00000000..a00d7ab8 --- /dev/null +++ b/src/lib/stores.ts @@ -0,0 +1,24 @@ +import { RuneStore } from "@tauri-store/svelte"; +import type { SplitNode } from "./split-layout"; + +interface Layout { + [key: string]: unknown; + root: SplitNode | null; +} + +export const layout = new RuneStore( + "layout", + { root: null }, + { + autoStart: true, + hooks: { + beforeBackendSync(state) { + if (typeof state.root === "string") { + state.root = null; + } + + return state; + }, + }, + }, +); diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index a8fb4ece..5fc61aa5 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -8,6 +8,7 @@ import { buttonVariants } from "$lib/components/ui/button"; import * as Empty from "$lib/components/ui/empty"; import { settings } from "$lib/settings"; + import { layout } from "$lib/stores"; let loading = $state(true); @@ -18,8 +19,8 @@ await app.user.fetchEmoteSets(); } - if (settings.state.layout) { - app.splits.root = settings.state.layout; + if (layout.state.root) { + app.splits.root = layout.state.root; await goto("/channels/split"); } else if (settings.state.lastJoined) { From ab7aaa2c91f0824f7cd3dc2717c6b6398d69c8cc Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 23:31:36 -0500 Subject: [PATCH 22/41] arrow navigation --- src/lib/components/chat/Input.svelte | 2 +- src/lib/menus/channel-menu.ts | 4 +- src/lib/split-layout.ts | 129 ++++++++++++++++++ src/routes/(main)/channels/split/+page.svelte | 41 ++++++ 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/src/lib/components/chat/Input.svelte b/src/lib/components/chat/Input.svelte index de9c8085..1ead9dfb 100644 --- a/src/lib/components/chat/Input.svelte +++ b/src/lib/components/chat/Input.svelte @@ -29,7 +29,7 @@ const showSuggestions = $derived(!!completer?.suggestions.length && completer.prefixed); $effect(() => { - chat.input ??= input; + chat.input = input; completer = new Completer(chat); }); diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index 3fa3f407..fa459b4d 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -4,9 +4,7 @@ import { page } from "$app/state"; import { app } from "$lib/app.svelte"; import type { Channel } from "$lib/models/channel.svelte"; import { settings } from "$lib/settings"; -import type { SplitBranch } from "$lib/split-layout"; - -type SplitDirection = "up" | "down" | "left" | "right"; +import type { SplitBranch, SplitDirection } from "$lib/split-layout"; async function splitItem(channel: Channel, direction: SplitDirection) { return MenuItem.new({ diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index ca8d0d01..527b5dc6 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -2,6 +2,8 @@ import type { DragDropEvents } from "@dnd-kit-svelte/svelte"; import type { PaneGroupProps } from "paneforge"; import { layout } from "./stores"; +export type SplitDirection = "up" | "down" | "left" | "right"; + type SplitAxis = PaneGroupProps["direction"]; export interface SplitBranch { @@ -15,6 +17,14 @@ export type SplitNode = SplitBranch | string; type SplitPath = ("before" | "after")[]; +interface SplitRect { + id: string; + x: number; + y: number; + width: number; + height: number; +} + export class SplitLayout { public get root() { return layout.state.root; @@ -83,6 +93,60 @@ export class SplitLayout { this.#update(target, () => replacement); } + public navigate(startId: string, direction: SplitDirection) { + if (!this.root || this.root === startId) return null; + + const rects = this.#getLayoutRects(this.root); + const current = rects.find((r) => r.id === startId); + if (!current) return null; + + const threshold = 0.001; + + const candidates = rects.filter((rect) => { + if (rect.id === startId) return false; + + switch (direction) { + case "up": { + return rect.y + rect.height <= current.y + threshold; + } + + case "down": { + return rect.y >= current.y + current.height - threshold; + } + + case "left": { + return rect.x + rect.width <= current.x + threshold; + } + + case "right": { + return rect.x >= current.x + current.width - threshold; + } + + default: { + return false; + } + } + }); + + if (!candidates.length) return null; + + const [best] = candidates.sort((a, b) => { + const distA = this.#getDistance(current, a, direction); + const distB = this.#getDistance(current, b, direction); + + if (Math.abs(distA - distB) > threshold) { + return distA - distB; + } + + return ( + this.#getAlignmentScore(current, b, direction) - + this.#getAlignmentScore(current, a, direction) + ); + }); + + return best.id; + } + public handleDragEnd(event: Parameters[0]) { const { source, target } = event.operation; if (!source || !target || source.id === target.id) return; @@ -162,4 +226,69 @@ export class SplitLayout { after: this.#remove(node.after, target), }; } + + #getLayoutRects( + node: SplitNode, + { x, y, width, height }: Omit = { x: 0, y: 0, width: 1, height: 1 }, + ): SplitRect[] { + if (typeof node === "string") { + return [{ id: node, x, y, width, height }]; + } + + const isRow = node.axis === "horizontal"; + const ratio = (node.size ?? 50) / 100; + + const sizeFirst = isRow ? width * ratio : height * ratio; + const sizeSecond = isRow ? width * (1 - ratio) : height * (1 - ratio); + + return [ + ...this.#getLayoutRects(node.before, { + x, + y, + width: isRow ? sizeFirst : width, + height: !isRow ? sizeFirst : height, + }), + ...this.#getLayoutRects(node.after, { + x: isRow ? x + sizeFirst : x, + y: !isRow ? y + sizeFirst : y, + width: isRow ? sizeSecond : width, + height: !isRow ? sizeSecond : height, + }), + ]; + } + + #getDistance(from: SplitRect, to: SplitRect, direction: SplitDirection) { + switch (direction) { + case "up": { + return from.y - (to.y + to.height); + } + + case "down": { + return to.y - (from.y + from.height); + } + + case "left": { + return from.x - (to.x + to.width); + } + + case "right": { + return to.x - (from.x + from.width); + } + } + } + + #getAlignmentScore(from: SplitRect, to: SplitRect, direction: SplitDirection) { + const isVerticalMove = direction === "up" || direction === "down"; + + const startSrc = isVerticalMove ? from.x : from.y; + const endSrc = isVerticalMove ? from.x + from.width : from.y + from.height; + + const startTgt = isVerticalMove ? to.x : to.y; + const endTgt = isVerticalMove ? to.x + to.width : to.y + to.height; + + const overlapStart = Math.max(startSrc, startTgt); + const overlapEnd = Math.min(endSrc, endTgt); + + return Math.max(0, overlapEnd - overlapStart); + } } diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 86af4593..3cb1beb2 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -1,12 +1,53 @@ + +
{#if app.splits.root} From 532b9f4e08c9944213a104c0ac92d31e0adfa5cd Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 9 Dec 2025 23:47:48 -0500 Subject: [PATCH 23/41] persist sizes --- src/lib/components/split/SplitNode.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/split/SplitNode.svelte b/src/lib/components/split/SplitNode.svelte index 50ad068c..d971385d 100644 --- a/src/lib/components/split/SplitNode.svelte +++ b/src/lib/components/split/SplitNode.svelte @@ -15,7 +15,7 @@ {:else} - + (node.size = size)}> @@ -26,7 +26,7 @@ ]} /> - + (node.size = 100 - size)}> From f71f6505574dd64ea3cd99cce29a1cb4757ecf5a Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 10:05:04 -0500 Subject: [PATCH 24/41] add kb shortcut for new split --- src/routes/(main)/channels/split/+page.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 3cb1beb2..59913581 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -8,7 +8,7 @@ settings.state.lastJoined = null; - async function navigateSplit(event: KeyboardEvent) { + async function handleSplit(event: KeyboardEvent) { if (!app.focused || !(event.metaKey || event.ctrlKey)) return; let direction: SplitDirection; @@ -26,6 +26,11 @@ case "ArrowRight": direction = "right"; break; + case "\\": + event.preventDefault(); + app.splits.insertEmpty(app.focused.id, "horizontal"); + + return; default: return; } @@ -46,7 +51,7 @@ } - +
{#if app.splits.root} From 4cdaacf6c7bf7230e6e8bc54695436623d498156 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 10:05:14 -0500 Subject: [PATCH 25/41] fix binding --- src/lib/components/split/SplitNode.svelte | 6 +++--- src/routes/(main)/channels/split/+page.svelte | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/components/split/SplitNode.svelte b/src/lib/components/split/SplitNode.svelte index d971385d..744adc2e 100644 --- a/src/lib/components/split/SplitNode.svelte +++ b/src/lib/components/split/SplitNode.svelte @@ -8,7 +8,7 @@ node: SplitNode; } - const { node }: Props = $props(); + let { node = $bindable() }: Props = $props(); {#if typeof node === "string"} @@ -16,7 +16,7 @@ {:else} (node.size = size)}> - + (node.size = 100 - size)}> - + {/if} diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 59913581..0e8139e5 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -55,7 +55,7 @@
{#if app.splits.root} - + {:else} {/if} From 8325817c114d0ae62048c95897fe501a71fe29e1 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 10:15:25 -0500 Subject: [PATCH 26/41] remove z index on header --- src/lib/components/split/SplitHeader.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/split/SplitHeader.svelte b/src/lib/components/split/SplitHeader.svelte index 37c750d2..16a11a59 100644 --- a/src/lib/components/split/SplitHeader.svelte +++ b/src/lib/components/split/SplitHeader.svelte @@ -33,7 +33,7 @@ } -
+
Date: Wed, 10 Dec 2025 10:29:48 -0500 Subject: [PATCH 27/41] move new split shortcut to channel route layout --- src/lib/menus/channel-menu.ts | 3 +-- src/lib/split-layout.ts | 5 ++++ src/routes/(main)/channels/+layout.svelte | 25 +++++++++++++++++++ src/routes/(main)/channels/split/+page.svelte | 9 ++----- 4 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 src/routes/(main)/channels/+layout.svelte diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index fa459b4d..ac21d702 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -1,6 +1,5 @@ import { CheckMenuItem, Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { goto } from "$app/navigation"; -import { page } from "$app/state"; import { app } from "$lib/app.svelte"; import type { Channel } from "$lib/models/channel.svelte"; import { settings } from "$lib/settings"; @@ -31,7 +30,7 @@ async function splitItem(channel: Channel, direction: SplitDirection) { app.splits.insert(app.focused.id, channel.id, node); - if (page.route.id !== "/(main)/channels/split") { + if (!app.splits.active) { await goto("/channels/split"); } }, diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index 527b5dc6..0c983dae 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -1,5 +1,6 @@ import type { DragDropEvents } from "@dnd-kit-svelte/svelte"; import type { PaneGroupProps } from "paneforge"; +import { page } from "$app/state"; import { layout } from "./stores"; export type SplitDirection = "up" | "down" | "left" | "right"; @@ -26,6 +27,10 @@ interface SplitRect { } export class SplitLayout { + public get active() { + return page.route.id === "/(main)/channels/split"; + } + public get root() { return layout.state.root; } diff --git a/src/routes/(main)/channels/+layout.svelte b/src/routes/(main)/channels/+layout.svelte new file mode 100644 index 00000000..93a11182 --- /dev/null +++ b/src/routes/(main)/channels/+layout.svelte @@ -0,0 +1,25 @@ + + + + +{@render children()} diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 0e8139e5..10257a50 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -8,7 +8,7 @@ settings.state.lastJoined = null; - async function handleSplit(event: KeyboardEvent) { + async function navigateSplit(event: KeyboardEvent) { if (!app.focused || !(event.metaKey || event.ctrlKey)) return; let direction: SplitDirection; @@ -26,11 +26,6 @@ case "ArrowRight": direction = "right"; break; - case "\\": - event.preventDefault(); - app.splits.insertEmpty(app.focused.id, "horizontal"); - - return; default: return; } @@ -51,7 +46,7 @@ } - +
{#if app.splits.root} From 4c72342415e774410f8878f4dd5b6e717f3f4721 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 14:14:47 -0500 Subject: [PATCH 28/41] settings --- src/app.css | 8 ++ src/lib/settings.ts | 11 +++ src/lib/stores.ts | 6 +- src/routes/(main)/+layout.svelte | 2 +- src/routes/(main)/+page.svelte | 19 +++- src/routes/(main)/channels/+layout.svelte | 3 +- src/routes/(main)/channels/split/+page.svelte | 6 +- .../(main)/channels}/split/SplitHeader.svelte | 35 ++++++-- .../(main)/channels}/split/SplitNode.svelte | 0 .../(main)/channels}/split/SplitView.svelte | 2 +- src/routes/settings/FieldControl.svelte | 13 ++- src/routes/settings/categories/splits.ts | 86 +++++++++++++++++++ src/routes/settings/types.ts | 1 + 13 files changed, 173 insertions(+), 19 deletions(-) rename src/{lib/components => routes/(main)/channels}/split/SplitHeader.svelte (68%) rename src/{lib/components => routes/(main)/channels}/split/SplitNode.svelte (100%) rename src/{lib/components => routes/(main)/channels}/split/SplitView.svelte (96%) create mode 100644 src/routes/settings/categories/splits.ts diff --git a/src/app.css b/src/app.css index cf2d66a9..d11bb7db 100644 --- a/src/app.css +++ b/src/app.css @@ -160,4 +160,12 @@ color: var(--color-blue-400); text-decoration: underline; } + + code { + padding: 0.2rem 0.3rem; + font-size: 85%; + font-family: var(--font-mono); + border-radius: var(--radius-md); + background-color: var(--color-muted); + } } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 032b4672..f5c94eb0 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -34,6 +34,12 @@ interface StoredUser { export interface UserSettings { "appearance.theme": string; + "splits.defaultOrientation": "horizontal" | "vertical"; + "splits.singleRestoreBehavior": "preserve" | "redirect" | "remove"; + "splits.closeBehavior": "preserve" | "remove"; + "splits.leaveOnClose": boolean; + "splits.goToChannelAfterClose": boolean; + "chat.hideScrollbar": boolean; "chat.newSeparator": boolean; "chat.embeds": boolean; @@ -88,6 +94,11 @@ export const defaults: Settings = { pinned: [], "appearance.theme": "", + "splits.defaultOrientation": "horizontal", + "splits.singleRestoreBehavior": "redirect", + "splits.closeBehavior": "remove", + "splits.leaveOnClose": true, + "splits.goToChannelAfterClose": true, "chat.hideScrollbar": false, "chat.newSeparator": false, "chat.embeds": true, diff --git a/src/lib/stores.ts b/src/lib/stores.ts index a00d7ab8..f3437fa8 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -1,4 +1,5 @@ import { RuneStore } from "@tauri-store/svelte"; +import { settings } from "./settings"; import type { SplitNode } from "./split-layout"; interface Layout { @@ -13,7 +14,10 @@ export const layout = new RuneStore( autoStart: true, hooks: { beforeBackendSync(state) { - if (typeof state.root === "string") { + if ( + typeof state.root === "string" && + settings.state["splits.singleRestoreBehavior"] === "remove" + ) { state.root = null; } diff --git a/src/routes/(main)/+layout.svelte b/src/routes/(main)/+layout.svelte index e2bf9013..6a152a40 100644 --- a/src/routes/(main)/+layout.svelte +++ b/src/routes/(main)/+layout.svelte @@ -4,9 +4,9 @@ import { goto } from "$app/navigation"; import { app } from "$lib/app.svelte"; import Sidebar from "$lib/components/Sidebar.svelte"; - import SplitHeader from "$lib/components/split/SplitHeader.svelte"; import * as Tooltip from "$lib/components/ui/tooltip"; import { settings } from "$lib/settings"; + import SplitHeader from "./channels/split/SplitHeader.svelte"; const { children } = $props(); diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index 5fc61aa5..b6ef0f72 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -20,10 +20,23 @@ } if (layout.state.root) { - app.splits.root = layout.state.root; + const restoreBehavior = settings.state["splits.singleRestoreBehavior"]; + const isBranch = typeof layout.state.root !== "string"; - await goto("/channels/split"); - } else if (settings.state.lastJoined) { + if (isBranch || restoreBehavior === "preserve") { + app.splits.root = layout.state.root; + await goto("/channels/split"); + + return; + } + + if (restoreBehavior === "redirect") { + const channel = app.channels.get(layout.state.root as string); + settings.state.lastJoined = channel?.user.username ?? null; + } + } + + if (settings.state.lastJoined) { await goto(`/channels/${settings.state.lastJoined}`); } diff --git a/src/routes/(main)/channels/+layout.svelte b/src/routes/(main)/channels/+layout.svelte index 93a11182..9615a93b 100644 --- a/src/routes/(main)/channels/+layout.svelte +++ b/src/routes/(main)/channels/+layout.svelte @@ -1,6 +1,7 @@ diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 10257a50..2b213cd0 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -1,10 +1,10 @@ diff --git a/src/lib/components/split/SplitNode.svelte b/src/routes/(main)/channels/split/SplitNode.svelte similarity index 100% rename from src/lib/components/split/SplitNode.svelte rename to src/routes/(main)/channels/split/SplitNode.svelte diff --git a/src/lib/components/split/SplitView.svelte b/src/routes/(main)/channels/split/SplitView.svelte similarity index 96% rename from src/lib/components/split/SplitView.svelte rename to src/routes/(main)/channels/split/SplitView.svelte index 294ca37f..096f38bf 100644 --- a/src/lib/components/split/SplitView.svelte +++ b/src/routes/(main)/channels/split/SplitView.svelte @@ -1,7 +1,7 @@ - - -
(app.focused = channel)}> +
{#if channel.stream} {/if} diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index 8ba72161..1b651b4d 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -7,8 +7,8 @@ import type { SplitBranch, SplitDirection } from "$lib/split-layout"; async function splitItem(channel: Channel, direction: SplitDirection) { const enabled = - app.focused !== null && - app.focused.id !== channel.id && + app.splits.focused !== null && + app.splits.focused !== channel.id && !app.splits.contains(app.splits.root!, channel.id); return MenuItem.new({ @@ -18,22 +18,22 @@ async function splitItem(channel: Channel, direction: SplitDirection) { async action() { await channel.join(true); - if (!app.focused) return; + if (!app.splits.focused) return; - app.splits.root ??= app.focused.id; + app.splits.root ??= app.splits.focused; const node: SplitBranch = { axis: direction === "up" || direction === "down" ? "vertical" : "horizontal", before: channel.id, - after: app.focused.id, + after: app.splits.focused, }; if (direction === "down" || direction === "right") { - node.before = app.focused.id; + node.before = app.splits.focused; node.after = channel.id; } - app.splits.insert(app.focused.id, channel.id, node); + app.splits.insert(app.splits.focused, channel.id, node); if (!app.splits.active) { await goto("/channels/split"); @@ -63,7 +63,7 @@ export async function createChannelMenu(channel: Channel) { async action() { await channel.leave(); - if (app.focused === channel) { + if (!app.splits.active && app.focused === channel) { await goto("/"); } }, diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index be4b1b44..087b793a 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -27,6 +27,8 @@ interface SplitRect { } export class SplitLayout { + #focused: string | null = null; + public get active() { return page.route.id === "/(main)/channels/split"; } @@ -35,6 +37,14 @@ export class SplitLayout { return layout.state.root; } + public get focused() { + return this.#focused; + } + + public set focused(value: string | null) { + this.#focused = value; + } + public set root(value: SplitNode | null) { layout.state.root = value; } @@ -67,6 +77,8 @@ export class SplitLayout { before: target, after: id, }); + + this.focused = id; } public remove(target: string) { diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index b6ef0f72..adf0aef9 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -32,6 +32,8 @@ if (restoreBehavior === "redirect") { const channel = app.channels.get(layout.state.root as string); + + layout.state.root = null; settings.state.lastJoined = channel?.user.username ?? null; } } diff --git a/src/routes/(main)/channels/+layout.svelte b/src/routes/(main)/channels/+layout.svelte index 9615a93b..285c5297 100644 --- a/src/routes/(main)/channels/+layout.svelte +++ b/src/routes/(main)/channels/+layout.svelte @@ -6,17 +6,34 @@ const { children } = $props(); async function handleKeydown(event: KeyboardEvent) { - if (!app.focused || !(event.metaKey || event.ctrlKey)) return; + if (!(event.metaKey || event.ctrlKey)) return; - if (event.key === "\\") { - event.preventDefault(); + switch (event.key) { + case "t": { + if (!app.focused) return; - if (!app.splits.active) { - app.splits.root = app.focused.id; - await goto("/channels/split"); + if (!app.splits.active) { + app.splits.root = app.focused.id; + await goto("/channels/split"); + } + + app.splits.insertEmpty(app.focused.id, settings.state["splits.defaultOrientation"]); + break; } - app.splits.insertEmpty(app.focused.id, settings.state["splits.defaultOrientation"]); + case "w": { + if (app.splits.active && app.splits.focused) { + event.preventDefault(); + app.splits.remove(app.splits.focused); + } else if (app.focused) { + event.preventDefault(); + + await app.focused.leave(); + await goto("/"); + } + + break; + } } } diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 2b213cd0..d213a928 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -1,5 +1,4 @@ -
+
(app.splits.focused = id)} + {@attach ref} +>
From 3d2868b4262bdc4a73ec2f61a8b1d80c6b8e1c1f Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 19:41:51 -0500 Subject: [PATCH 31/41] fix dragging onto blank --- src/lib/split-layout.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index 087b793a..5ac1fb5f 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -183,6 +183,11 @@ export class SplitLayout { this.remove(sourceId); + if (targetId.includes("blank")) { + this.root = sourceId; + return; + } + if (position === "center") { this.replace(targetId, sourceId); return; From c47d00e4bb68f383bbd74e9ac8372c963556fb95 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 20:24:04 -0500 Subject: [PATCH 32/41] overlay improvements --- .../(main)/channels/split/SplitView.svelte | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/routes/(main)/channels/split/SplitView.svelte b/src/routes/(main)/channels/split/SplitView.svelte index 3c62e464..4bce62c0 100644 --- a/src/routes/(main)/channels/split/SplitView.svelte +++ b/src/routes/(main)/channels/split/SplitView.svelte @@ -21,23 +21,41 @@ const dropRight = useDroppable({ id: () => `${id}:right` }); const channel = $derived(app.channels.get(id)); + + const activeClass = $derived.by(() => { + if (dropUp.isDropTarget.current) return "top-0 left-0 w-full h-1/2"; + if (dropDown.isDropTarget.current) return "top-1/2 left-0 w-full h-1/2"; + if (dropLeft.isDropTarget.current) return "top-0 left-0 w-1/2 h-full"; + if (dropRight.isDropTarget.current) return "top-0 left-1/2 w-1/2 h-full"; + if (dropCenter.isDropTarget.current) return "top-0 left-0 size-full"; + });
(app.splits.focused = id)} {@attach ref} > -
- {#if channel} - - {:else} -
- Empty Split -
- {/if} +
+
+ {#if channel} + + {:else} +
+ Empty Split +
+ {/if} +
+ +
{@render dropZone(dropCenter, "inset-0")} @@ -51,12 +69,5 @@
{#snippet dropZone(dropper: ReturnType, className: string)} -
+
{/snippet} From e58c80aa3f3c860551205f8ce00094fa4656d4a7 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 20:37:21 -0500 Subject: [PATCH 33/41] add empty state --- src/lib/split-layout.ts | 2 +- src/routes/(main)/channels/split/+page.svelte | 2 +- .../(main)/channels/split/SplitHeader.svelte | 2 +- .../(main)/channels/split/SplitView.svelte | 18 +++++++++++++++--- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index 5ac1fb5f..ac1ac192 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -183,7 +183,7 @@ export class SplitLayout { this.remove(sourceId); - if (targetId.includes("blank")) { + if (targetId.includes("empty")) { this.root = sourceId; return; } diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index d213a928..f226c915 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -50,6 +50,6 @@ {#if app.splits.root} {:else} - + {/if}
diff --git a/src/routes/(main)/channels/split/SplitHeader.svelte b/src/routes/(main)/channels/split/SplitHeader.svelte index d5629ce9..fb9be308 100644 --- a/src/routes/(main)/channels/split/SplitHeader.svelte +++ b/src/routes/(main)/channels/split/SplitHeader.svelte @@ -22,7 +22,7 @@ settings.state["splits.closeBehavior"] === "preserve" && !(id.startsWith("split-") || event.shiftKey); - if (app.splits.root === id || id.includes("blank")) { + if (app.splits.root === id || id.includes("empty")) { if (preserve) { app.splits.remove(id); diff --git a/src/routes/(main)/channels/split/SplitView.svelte b/src/routes/(main)/channels/split/SplitView.svelte index 4bce62c0..c2f304c4 100644 --- a/src/routes/(main)/channels/split/SplitView.svelte +++ b/src/routes/(main)/channels/split/SplitView.svelte @@ -1,7 +1,9 @@ - diff --git a/src/routes/(main)/+layout.svelte b/src/routes/(main)/+layout.svelte index 6a152a40..e040e90d 100644 --- a/src/routes/(main)/+layout.svelte +++ b/src/routes/(main)/+layout.svelte @@ -6,7 +6,6 @@ import Sidebar from "$lib/components/Sidebar.svelte"; import * as Tooltip from "$lib/components/ui/tooltip"; import { settings } from "$lib/settings"; - import SplitHeader from "./channels/split/SplitHeader.svelte"; const { children } = $props(); @@ -43,10 +42,21 @@ - {#snippet children(draggable)} - {#if draggable.type !== "channel-list-item"} - - {/if} + {#snippet children(source)} + {@const id = source.id.toString().split(":")[0]} + {@const channel = app.channels.get(id)} + +
+ {#if channel} + {channel.user.username} + {/if} + + {channel?.user.displayName ?? "Empty"} +
{/snippet}
diff --git a/src/routes/(main)/channels/split/SplitHeader.svelte b/src/routes/(main)/channels/split/SplitHeader.svelte index fb9be308..faa6c6ac 100644 --- a/src/routes/(main)/channels/split/SplitHeader.svelte +++ b/src/routes/(main)/channels/split/SplitHeader.svelte @@ -56,7 +56,7 @@ } -
+
-
+
+ + From 9dc5576bebe3a69577ddb1715fa54ef1bad8cfcb Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 21:39:30 -0500 Subject: [PATCH 35/41] fix --- src/routes/(main)/channels/+layout.svelte | 12 ++++++++---- src/routes/(main)/channels/split/+page.svelte | 5 +++++ src/routes/(main)/channels/split/SplitView.svelte | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/routes/(main)/channels/+layout.svelte b/src/routes/(main)/channels/+layout.svelte index 285c5297..650e1d9d 100644 --- a/src/routes/(main)/channels/+layout.svelte +++ b/src/routes/(main)/channels/+layout.svelte @@ -10,14 +10,18 @@ switch (event.key) { case "t": { - if (!app.focused) return; - - if (!app.splits.active) { + if (!app.splits.active && app.focused) { app.splits.root = app.focused.id; await goto("/channels/split"); } - app.splits.insertEmpty(app.focused.id, settings.state["splits.defaultOrientation"]); + if (app.splits.focused) { + app.splits.insertEmpty( + app.splits.focused, + settings.state["splits.defaultOrientation"], + ); + } + break; } diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index f226c915..24f8d2dd 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -5,8 +5,13 @@ import SplitNode from "./SplitNode.svelte"; import SplitView from "./SplitView.svelte"; + app.focused = null; settings.state.lastJoined = null; + if (typeof app.splits.root === "string") { + app.splits.focused = app.splits.root; + } + async function navigateSplit(event: KeyboardEvent) { if (!app.splits.focused || !(event.metaKey || event.ctrlKey)) return; diff --git a/src/routes/(main)/channels/split/SplitView.svelte b/src/routes/(main)/channels/split/SplitView.svelte index c2f304c4..24eeabe6 100644 --- a/src/routes/(main)/channels/split/SplitView.svelte +++ b/src/routes/(main)/channels/split/SplitView.svelte @@ -33,8 +33,11 @@ }); + +
(app.splits.focused = id)} onfocusin={() => (app.splits.focused = id)} {@attach ref} > From 6a5db3c067ef553bff23e409e317387b58903b82 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 21:48:34 -0500 Subject: [PATCH 36/41] button --- src/routes/(main)/+page.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index adf0aef9..1e0e5bee 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -5,7 +5,7 @@ import { goto } from "$app/navigation"; import { app } from "$lib/app.svelte"; import JoinDialog from "$lib/components/JoinDialog.svelte"; - import { buttonVariants } from "$lib/components/ui/button"; + import { Button, buttonVariants } from "$lib/components/ui/button"; import * as Empty from "$lib/components/ui/empty"; import { settings } from "$lib/settings"; import { layout } from "$lib/stores"; @@ -66,7 +66,11 @@ - Search channels +
+ Search channels + + +
{/if} From 1f76cd0ff94d0e37312ddbf07aca51cc691a4f7d Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 22:06:43 -0500 Subject: [PATCH 37/41] fix event placement --- .../(main)/channels/split/SplitView.svelte | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/routes/(main)/channels/split/SplitView.svelte b/src/routes/(main)/channels/split/SplitView.svelte index 24eeabe6..65bc0208 100644 --- a/src/routes/(main)/channels/split/SplitView.svelte +++ b/src/routes/(main)/channels/split/SplitView.svelte @@ -31,19 +31,18 @@ if (dropRight.isDropTarget.current) return "top-0 left-1/2 w-1/2 h-full"; if (dropCenter.isDropTarget.current) return "top-0 left-0 size-full"; }); + + function setFocus() { + app.splits.focused = id; + } - - -
(app.splits.focused = id)} - onfocusin={() => (app.splits.focused = id)} - {@attach ref} -> +
-
+ + +
{#if channel} From a594f99db0a8fb6b975dfe07b56f0c0504192fa4 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 22:06:48 -0500 Subject: [PATCH 38/41] style --- src/lib/split-layout.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index ac1ac192..43d0e52b 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -37,6 +37,10 @@ export class SplitLayout { return layout.state.root; } + public set root(value: SplitNode | null) { + layout.state.root = value; + } + public get focused() { return this.#focused; } @@ -45,10 +49,6 @@ export class SplitLayout { this.#focused = value; } - public set root(value: SplitNode | null) { - layout.state.root = value; - } - public insert(target: string, newNode: string, branch: SplitBranch) { if (!this.root) { this.root = target; From 03a4dcc4d0a6d82ad9108078c54efa97a553c348 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 22:09:31 -0500 Subject: [PATCH 39/41] fix --- src/lib/split-layout.ts | 4 ++++ src/routes/(main)/channels/split/+page.svelte | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index 43d0e52b..4445c11e 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -38,6 +38,10 @@ export class SplitLayout { } public set root(value: SplitNode | null) { + if (typeof value === "string") { + this.#focused = value; + } + layout.state.root = value; } diff --git a/src/routes/(main)/channels/split/+page.svelte b/src/routes/(main)/channels/split/+page.svelte index 24f8d2dd..c9918f8e 100644 --- a/src/routes/(main)/channels/split/+page.svelte +++ b/src/routes/(main)/channels/split/+page.svelte @@ -8,10 +8,6 @@ app.focused = null; settings.state.lastJoined = null; - if (typeof app.splits.root === "string") { - app.splits.focused = app.splits.root; - } - async function navigateSplit(event: KeyboardEvent) { if (!app.splits.focused || !(event.metaKey || event.ctrlKey)) return; From 613ace5e9378865a6d44025e05b85c0e541d13ac Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 22:53:06 -0500 Subject: [PATCH 40/41] cleanup --- src/lib/menus/channel-menu.ts | 3 ++- src/lib/split-layout.ts | 4 +++- src/routes/(main)/+page.svelte | 7 +++++++ src/routes/(main)/channels/+layout.svelte | 2 +- src/routes/(main)/channels/split/SplitHeader.svelte | 6 ++++-- src/routes/settings/categories/splits.ts | 2 +- 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index 1b651b4d..d63851b0 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -9,7 +9,8 @@ async function splitItem(channel: Channel, direction: SplitDirection) { const enabled = app.splits.focused !== null && app.splits.focused !== channel.id && - !app.splits.contains(app.splits.root!, channel.id); + app.splits.root !== null && + !app.splits.contains(app.splits.root, channel.id); return MenuItem.new({ id: `split-${direction}`, diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts index 4445c11e..278a4b0a 100644 --- a/src/lib/split-layout.ts +++ b/src/lib/split-layout.ts @@ -27,6 +27,8 @@ interface SplitRect { } export class SplitLayout { + public static readonly EMPTY_ROOT_ID = "split-root-empty"; + #focused: string | null = null; public get active() { @@ -187,7 +189,7 @@ export class SplitLayout { this.remove(sourceId); - if (targetId.includes("empty")) { + if (targetId === SplitLayout.EMPTY_ROOT_ID) { this.root = sourceId; return; } diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index 1e0e5bee..4519ced2 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -7,6 +7,7 @@ import JoinDialog from "$lib/components/JoinDialog.svelte"; import { Button, buttonVariants } from "$lib/components/ui/button"; import * as Empty from "$lib/components/ui/empty"; + import { log } from "$lib/log"; import { settings } from "$lib/settings"; import { layout } from "$lib/stores"; @@ -33,6 +34,12 @@ if (restoreBehavior === "redirect") { const channel = app.channels.get(layout.state.root as string); + if (!channel) { + log.warn( + `Could not restore single split: channel with id ${layout.state.root} not found`, + ); + } + layout.state.root = null; settings.state.lastJoined = channel?.user.username ?? null; } diff --git a/src/routes/(main)/channels/+layout.svelte b/src/routes/(main)/channels/+layout.svelte index 650e1d9d..75fc039e 100644 --- a/src/routes/(main)/channels/+layout.svelte +++ b/src/routes/(main)/channels/+layout.svelte @@ -6,7 +6,7 @@ const { children } = $props(); async function handleKeydown(event: KeyboardEvent) { - if (!(event.metaKey || event.ctrlKey)) return; + if ((!event.metaKey && !event.ctrlKey) || event.repeat) return; switch (event.key) { case "t": { diff --git a/src/routes/(main)/channels/split/SplitHeader.svelte b/src/routes/(main)/channels/split/SplitHeader.svelte index faa6c6ac..a82c00f8 100644 --- a/src/routes/(main)/channels/split/SplitHeader.svelte +++ b/src/routes/(main)/channels/split/SplitHeader.svelte @@ -7,6 +7,7 @@ import { app } from "$lib/app.svelte"; import { Button } from "$lib/components/ui/button"; import { settings } from "$lib/settings"; + import { SplitLayout } from "$lib/split-layout"; interface Props { id: string; @@ -20,9 +21,10 @@ async function closeSplit(event: MouseEvent) { const preserve = settings.state["splits.closeBehavior"] === "preserve" && - !(id.startsWith("split-") || event.shiftKey); + !id.startsWith("split-") && + !event.shiftKey; - if (app.splits.root === id || id.includes("empty")) { + if (app.splits.root === id || id === SplitLayout.EMPTY_ROOT_ID) { if (preserve) { app.splits.remove(id); diff --git a/src/routes/settings/categories/splits.ts b/src/routes/settings/categories/splits.ts index 127225c8..1039fcd3 100644 --- a/src/routes/settings/categories/splits.ts +++ b/src/routes/settings/categories/splits.ts @@ -59,7 +59,7 @@ export default { label: "Preserve", value: "preserve", description: - "Preserve the existing layout by replacing it with an empty split. If the split is already empty, it will be removed. Splits can be force closed with this option by shift clicking close.", + "Preserve the existing layout by replacing it with an empty split. If the split is already empty, it will be removed. Splits can be force closed with this option by Shift clicking close.", }, { label: "Remove", From d9ea5d1377fb6d615eef02eb1fca73a438a2a58a Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 10 Dec 2025 23:09:20 -0500 Subject: [PATCH 41/41] update condition --- src/lib/menus/channel-menu.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/menus/channel-menu.ts b/src/lib/menus/channel-menu.ts index d63851b0..48807339 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -44,6 +44,8 @@ async function splitItem(channel: Channel, direction: SplitDirection) { } export async function createChannelMenu(channel: Channel) { + const singleConnection = settings.state["advanced.singleConnection"]; + const separator = await PredefinedMenuItem.new({ item: "Separator", }); @@ -70,10 +72,12 @@ export async function createChannelMenu(channel: Channel) { }, }); + const isEmpty = typeof app.splits.root === "string" && app.splits.root.startsWith("split-"); + const openInSplit = await MenuItem.new({ id: "open-in-split", text: "Open in Split View", - enabled: !settings.state["advanced.singleConnection"] && !app.splits.active, + enabled: !singleConnection && (!app.splits.active || isEmpty), async action() { app.splits.root = channel.id; await goto("/channels/split"); @@ -109,7 +113,7 @@ export async function createChannelMenu(channel: Channel) { const items = [join, leave, pin, separator, openInSplit]; - if (app.splits.active && !settings.state["advanced.singleConnection"]) { + if (app.splits.active && !singleConnection) { const splitItems = await Promise.all([ splitItem(channel, "up"), splitItem(channel, "down"),