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/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/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/app.svelte.ts b/src/lib/app.svelte.ts index d1d858d8..b3f34fb1 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 { SplitLayout } from "./split-layout"; 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 SplitLayout(); + /** * Route history. */ diff --git a/src/lib/components/Channel.svelte b/src/lib/components/Channel.svelte new file mode 100644 index 00000000..60a55f4f --- /dev/null +++ b/src/lib/components/Channel.svelte @@ -0,0 +1,43 @@ + + +
+ {#if channel.stream} + + {/if} + + + +
+ +
+
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/ChannelListItem.svelte b/src/lib/components/ChannelListItem.svelte index 988759cd..e82149e9 100644 --- a/src/lib/components/ChannelListItem.svelte +++ b/src/lib/components/ChannelListItem.svelte @@ -1,18 +1,16 @@ - -
diff --git a/src/lib/components/DraggableChannel.svelte b/src/lib/components/DraggableChannel.svelte new file mode 100644 index 00000000..4730d297 --- /dev/null +++ b/src/lib/components/DraggableChannel.svelte @@ -0,0 +1,29 @@ + + +
+
+ +
+ + {#if isDragging.current} +
+ +
+ {/if} +
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/StreamTooltip.svelte b/src/lib/components/StreamTooltip.svelte index 2f4e8e61..90cc6a3b 100644 --- a/src/lib/components/StreamTooltip.svelte +++ b/src/lib/components/StreamTooltip.svelte @@ -1,16 +1,20 @@ @@ -24,6 +28,7 @@ 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 1efea7ee..48807339 100644 --- a/src/lib/menus/channel-menu.ts +++ b/src/lib/menus/channel-menu.ts @@ -3,8 +3,49 @@ import { goto } from "$app/navigation"; 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"; + +async function splitItem(channel: Channel, direction: SplitDirection) { + const enabled = + app.splits.focused !== null && + app.splits.focused !== channel.id && + app.splits.root !== null && + !app.splits.contains(app.splits.root, channel.id); + + return MenuItem.new({ + id: `split-${direction}`, + text: `Split ${direction.charAt(0).toUpperCase() + direction.slice(1)}`, + enabled, + async action() { + await channel.join(true); + + if (!app.splits.focused) return; + + app.splits.root ??= app.splits.focused; + + const node: SplitBranch = { + axis: direction === "up" || direction === "down" ? "vertical" : "horizontal", + before: channel.id, + after: app.splits.focused, + }; + + if (direction === "down" || direction === "right") { + node.before = app.splits.focused; + node.after = channel.id; + } + + app.splits.insert(app.splits.focused, channel.id, node); + + if (!app.splits.active) { + await goto("/channels/split"); + } + }, + }); +} export async function createChannelMenu(channel: Channel) { + const singleConnection = settings.state["advanced.singleConnection"]; + const separator = await PredefinedMenuItem.new({ item: "Separator", }); @@ -25,12 +66,24 @@ export async function createChannelMenu(channel: Channel) { async action() { await channel.leave(); - if (app.focused === channel) { + if (!app.splits.active && app.focused === channel) { await goto("/"); } }, }); + 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: !singleConnection && (!app.splits.active || isEmpty), + async action() { + app.splits.root = channel.id; + await goto("/channels/split"); + }, + }); + const pin = await CheckMenuItem.new({ id: "pin", text: "Pin", @@ -51,7 +104,6 @@ export async function createChannelMenu(channel: Channel) { const remove = await MenuItem.new({ id: "remove", text: "Remove", - enabled: channel.ephemeral, async action() { await channel.leave(); app.channels.delete(channel.id); @@ -59,7 +111,22 @@ export async function createChannelMenu(channel: Channel) { }, }); - return Menu.new({ - items: [join, leave, pin, separator, remove], - }); + const items = [join, leave, pin, separator, openInSplit]; + + if (app.splits.active && !singleConnection) { + const splitItems = await Promise.all([ + splitItem(channel, "up"), + splitItem(channel, "down"), + splitItem(channel, "left"), + splitItem(channel, "right"), + ]); + + items.push(separator, ...splitItems); + } + + if (channel.ephemeral) { + items.push(separator, remove); + } + + return Menu.new({ items }); } diff --git a/src/lib/models/channel.svelte.ts b/src/lib/models/channel.svelte.ts index 9836253f..34585036 100644 --- a/src/lib/models/channel.svelte.ts +++ b/src/lib/models/channel.svelte.ts @@ -88,12 +88,16 @@ export class Channel { this.pinned = settings.state.pinned.includes(this.id); } - public async join() { + public async join(split = false) { + if (this.joined) return; + 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/lib/settings.ts b/src/lib/settings.ts index 6cdecc32..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, @@ -114,4 +125,4 @@ export const defaults: Settings = { "advanced.logs.level": "info", }; -export const settings = new RuneStore("settings", defaults); +export const settings = new RuneStore("settings", defaults, { autoStart: true }); diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts new file mode 100644 index 00000000..278a4b0a --- /dev/null +++ b/src/lib/split-layout.ts @@ -0,0 +1,330 @@ +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"; + +type SplitAxis = PaneGroupProps["direction"]; + +export interface SplitBranch { + axis: SplitAxis; + size?: number; + before: SplitNode; + after: SplitNode; +} + +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 static readonly EMPTY_ROOT_ID = "split-root-empty"; + + #focused: string | null = null; + + public get active() { + return page.route.id === "/(main)/channels/split"; + } + + public get root() { + return layout.state.root; + } + + public set root(value: SplitNode | null) { + if (typeof value === "string") { + this.#focused = value; + } + + layout.state.root = value; + } + + public get focused() { + return this.#focused; + } + + public set focused(value: string | null) { + this.#focused = value; + } + + public insert(target: string, newNode: string, branch: SplitBranch) { + if (!this.root) { + this.root = target; + return; + } + + this.#update(target, (node) => { + if (typeof node === "string") { + return { ...branch, size: 50 }; + } + + return { + axis: branch.axis, + before: node, + after: newNode, + size: 50, + }; + }); + } + + public insertEmpty(target: string, axis: SplitAxis) { + const id = `split-${crypto.randomUUID()}`; + + this.insert(target, id, { + axis, + before: target, + after: id, + }); + + this.focused = id; + } + + public remove(target: string) { + if (!this.root) return; + + if (this.root === target) { + this.root = null; + return; + } + + if (typeof this.root !== "string") { + if (this.root.before === target) { + this.root = this.root.after; + return; + } + + if (this.root.after === target) { + this.root = this.root.before; + return; + } + } + + this.root = this.#remove(this.root, target); + } + + public replace(target: string, replacement: string) { + if (!this.root || target === replacement) return; + + 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 contains(node: SplitNode, id: string): boolean { + if (typeof node === "string") { + return node === id; + } + + return this.contains(node.before, id) || this.contains(node.after, id); + } + + public handleDragEnd(event: Parameters[0]) { + const { source, target } = event.operation; + if (!source || !target || source.id === target.id) return; + + const [sourceId] = source.id.toString().split(":"); + const [targetId, position] = target.id.toString().split(":"); + + if (sourceId === targetId) return; + + this.remove(sourceId); + + if (targetId === SplitLayout.EMPTY_ROOT_ID) { + this.root = sourceId; + return; + } + + if (position === "center") { + this.replace(targetId, sourceId); + return; + } + + const isVertical = position === "up" || position === "down"; + const isFirst = position === "up" || position === "left"; + + this.insert(targetId, sourceId, { + axis: isVertical ? "vertical" : "horizontal", + before: isFirst ? sourceId : targetId, + after: isFirst ? targetId : sourceId, + }); + } + + #find(node: SplitNode, target: string): SplitPath | null { + if (typeof node === "string") { + return node === target ? [] : null; + } + + const bPath = this.#find(node.before, target); + if (bPath) return ["before", ...bPath]; + + const aPath = this.#find(node.after, target); + if (aPath) return ["after", ...aPath]; + + return null; + } + + #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, + updater: (node: SplitNode) => SplitNode, + ): SplitNode { + if (!path.length) return updater(node); + + if (typeof node === "string") { + throw new TypeError("Path continues but node is a leaf"); + } + + const [side, ...rest] = path; + + return { + ...node, + [side]: this.#applyUpdate(node[side], rest, updater), + }; + } + + #remove(node: SplitNode, target: string): SplitNode { + if (typeof node === "string") return node; + + if (node.before === target) return node.after; + if (node.after === target) return node.before; + + return { + ...node, + before: this.#remove(node.before, target), + 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/lib/stores.ts b/src/lib/stores.ts new file mode 100644 index 00000000..f3437fa8 --- /dev/null +++ b/src/lib/stores.ts @@ -0,0 +1,28 @@ +import { RuneStore } from "@tauri-store/svelte"; +import { settings } from "./settings"; +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" && + settings.state["splits.singleRestoreBehavior"] === "remove" + ) { + state.root = null; + } + + return state; + }, + }, + }, +); diff --git a/src/routes/(main)/+layout.svelte b/src/routes/(main)/+layout.svelte index 66f5330f..e040e90d 100644 --- a/src/routes/(main)/+layout.svelte +++ b/src/routes/(main)/+layout.svelte @@ -1,5 +1,8 @@ + + + +{@render children()} diff --git a/src/routes/(main)/channels/[username]/+page.svelte b/src/routes/(main)/channels/[username]/+page.svelte index c3eef33a..bf3d0efa 100644 --- a/src/routes/(main)/channels/[username]/+page.svelte +++ b/src/routes/(main)/channels/[username]/+page.svelte @@ -1,36 +1,7 @@ -
- {#if data.channel.stream} - - {/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..c9918f8e --- /dev/null +++ b/src/routes/(main)/channels/split/+page.svelte @@ -0,0 +1,56 @@ + + + + +
+ {#if app.splits.root} + + {:else} + + {/if} +
diff --git a/src/routes/(main)/channels/split/SplitHeader.svelte b/src/routes/(main)/channels/split/SplitHeader.svelte new file mode 100644 index 00000000..a82c00f8 --- /dev/null +++ b/src/routes/(main)/channels/split/SplitHeader.svelte @@ -0,0 +1,113 @@ + + +
+
+ {#if channel} + {channel.user.displayName} + + {channel.user.displayName} + {/if} +
+ +
+ + + + + +
+
+ + diff --git a/src/routes/(main)/channels/split/SplitNode.svelte b/src/routes/(main)/channels/split/SplitNode.svelte new file mode 100644 index 00000000..744adc2e --- /dev/null +++ b/src/routes/(main)/channels/split/SplitNode.svelte @@ -0,0 +1,33 @@ + + +{#if typeof node === "string"} + +{:else} + + (node.size = size)}> + + + + + + (node.size = 100 - size)}> + + + +{/if} diff --git a/src/routes/(main)/channels/split/SplitView.svelte b/src/routes/(main)/channels/split/SplitView.svelte new file mode 100644 index 00000000..65bc0208 --- /dev/null +++ b/src/routes/(main)/channels/split/SplitView.svelte @@ -0,0 +1,87 @@ + + +
+ + + + +
+
+ {#if channel} + + {:else} + + + + + + + Empty split + + + Drag a channel from the channel list to add it as a split. + + + + {/if} +
+ +
+ + {@render dropZone(dropCenter, "inset-0")} + + {#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} +
+
+ +{#snippet dropZone(dropper: ReturnType, className: string)} +
+{/snippet} diff --git a/src/routes/settings/FieldControl.svelte b/src/routes/settings/FieldControl.svelte index 63e5e719..1cb57c7b 100644 --- a/src/routes/settings/FieldControl.svelte +++ b/src/routes/settings/FieldControl.svelte @@ -71,11 +71,18 @@ bind:value={settings.state[field.id]} > {#each field.items as option (option.value)} - - + + - {option.label} + + {option.label} + + {@render description(option.description)} + {/each} 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", diff --git a/src/routes/settings/categories/splits.ts b/src/routes/settings/categories/splits.ts new file mode 100644 index 00000000..1039fcd3 --- /dev/null +++ b/src/routes/settings/categories/splits.ts @@ -0,0 +1,86 @@ +import Layout from "~icons/ph/layout"; +import type { SettingsCategory } from "../types"; + +export default { + order: 15, + label: "Splits", + icon: Layout, + fields: [ + { + id: "splits.defaultOrientation", + type: "radio", + label: "Default orientation", + description: + "Choose the default orientation when opening a new split with the keyboard.", + items: [ + { + label: "Horizontal", + value: "horizontal", + description: "New splits will open to the right of the focused split.", + }, + { + label: "Vertical", + value: "vertical", + description: "New splits will open below the focused split.", + }, + ], + }, + { + id: "splits.singleRestoreBehavior", + type: "radio", + label: "Restore behavior for a single split", + description: "Choose what happens when restoring a layout with a single split.", + items: [ + { + label: "Preserve", + value: "preserve", + description: "Preserve the single split and restore it by itself.", + }, + { + label: "Redirect", + value: "redirect", + description: + "Redirect to the channel contained in the split if it's not ephemeral and exit the layout.", + }, + { + label: "Remove", + value: "remove", + description: "Remove the split without redirecting and exit the layout.", + }, + ], + }, + { + id: "splits.closeBehavior", + type: "radio", + label: "Close behavior", + description: "Choose what happens when closing a split.", + items: [ + { + 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.", + }, + { + label: "Remove", + value: "remove", + description: + "Remove it from the layout and adjust remaining splits accordingly.", + }, + ], + }, + { + id: "splits.leaveOnClose", + type: "switch", + label: "Leave channel on close", + description: "Automatically leave the channel contained in a split when closing it.", + }, + { + id: "splits.goToChannelAfterClose", + type: "switch", + label: "Go to channel after closing last split", + description: + "If the closed split was the last one in the layout, navigate to the channel it contained. If the close behavior is set to Preserve, this will have no effect unless the split is force closed.", + }, + ], +} satisfies SettingsCategory; diff --git a/src/routes/settings/types.ts b/src/routes/settings/types.ts index afb7d185..6a6d7a96 100644 --- a/src/routes/settings/types.ts +++ b/src/routes/settings/types.ts @@ -28,6 +28,7 @@ interface InputField extends BaseField { interface ChoiceItem { label: string; value: string; + description?: string; } interface RadioField extends BaseField {