slint-ui-system 0.5.0

Neon Design System — Slint UI components for Rust desktop apps. 35+ components, dark/light theme, neon accents.
// ═══════════════════════════════════════════════════════════════════
//  Splitter primitives — runtime-resizable dividers
// ═══════════════════════════════════════════════════════════════════
//
//  Slint's `@children` slot can only appear ONCE per component, so
//  a single "two-slot split view" component is structurally
//  awkward. Instead this file ships the primitive — the divider
//  handle with drag logic — and lets the caller arrange the two
//  panels via a regular HorizontalLayout / VerticalLayout.
//
//  Two helpers:
//
//    NeonSplitterHandle    — col-resize divider (4px wide).
//    NeonVSplitterHandle   — row-resize divider (4px tall).
//
//  Mounting pattern (horizontal split):
//
//    in-out property <length> left-w: 240px;
//    HorizontalLayout {
//        spacing: 0px;
//        Rectangle { width: root.left-w; /* left content */ }
//        NeonSplitterHandle {
//            position <=> root.left-w;
//            bound-min: 120px;
//            bound-max: parent.width - 200px;
//        }
//        Rectangle { horizontal-stretch: 1; /* right content */ }
//    }
//
//  Vertical split: same shape with VerticalLayout +
//  NeonVSplitterHandle. The host owns the position; the handle
//  mutates it via drag.
//
//  Why a primitive instead of a "two-children" component:
//   • Avoids the `@children` once-per-component limit cleanly.
//   • Lets the caller decide how to compose the panels (extra
//     layers, custom backgrounds, scroll containers, etc.).
//   • Drop-in compatible with std HorizontalLayout / VerticalLayout
//     so designers don't need to learn a new layout idiom.
//
//  The previous version's bugs (hardcoded 600px upper bound,
//  per-event grab reset jitter) are gone here — the drag stores
//  grab-x and start-pos at pointer-down and computes a cumulative
//  delta on each `moved` event.
//
// ═══════════════════════════════════════════════════════════════════

import { Theme } from "../theme.slint";

// ── Horizontal handle — vertical bar, col-resize cursor ─────────────
export component NeonSplitterHandle inherits Rectangle {
    /// Two-way bound to the leading panel's width. The handle
    /// mutates this on drag; the caller binds it to whatever
    /// state owns the split layout.
    in-out property <length> position;

    /// Hard bounds applied during drag. `bound-max` typically
    /// uses `parent.width - <trailing min>` so the trailing panel
    /// never collapses below its minimum.
    in property <length> bound-min: 80px;
    in property <length> bound-max: 4000px;

    in property <length> thickness: 4px;
    in property <color> idle-color: Theme.border;
    in property <color> active-color: Theme.neon-cyan;

    callback dragged;

    width: thickness;
    background: drag.has-hover || drag.pressed
        ? root.active-color
        : root.idle-color;
    animate background { duration: Theme.transition-fast; }

    drag := TouchArea {
        mouse-cursor: col-resize;
        property <length> grab-x: 0px;
        property <length> start-pos: 0px;
        property <bool> dragging: false;
        pointer-event(e) => {
            if (e.kind == PointerEventKind.down
                && e.button == PointerEventButton.left)
            {
                self.grab-x = self.mouse-x;
                self.start-pos = root.position;
                self.dragging = true;
            }
            if (e.kind == PointerEventKind.up) {
                self.dragging = false;
            }
        }
        moved => {
            if (self.dragging) {
                root.position = clamp(
                    self.start-pos + self.mouse-x - self.grab-x,
                    root.bound-min,
                    max(root.bound-min, root.bound-max),
                );
                root.dragged();
            }
        }
    }
}

// ── Vertical handle — horizontal bar, row-resize cursor ─────────────
export component NeonVSplitterHandle inherits Rectangle {
    in-out property <length> position;
    in property <length> bound-min: 60px;
    in property <length> bound-max: 4000px;
    in property <length> thickness: 4px;
    in property <color> idle-color: Theme.border;
    in property <color> active-color: Theme.neon-cyan;

    callback dragged;

    height: thickness;
    background: drag.has-hover || drag.pressed
        ? root.active-color
        : root.idle-color;
    animate background { duration: Theme.transition-fast; }

    drag := TouchArea {
        mouse-cursor: row-resize;
        property <length> grab-y: 0px;
        property <length> start-pos: 0px;
        property <bool> dragging: false;
        pointer-event(e) => {
            if (e.kind == PointerEventKind.down
                && e.button == PointerEventButton.left)
            {
                self.grab-y = self.mouse-y;
                self.start-pos = root.position;
                self.dragging = true;
            }
            if (e.kind == PointerEventKind.up) {
                self.dragging = false;
            }
        }
        moved => {
            if (self.dragging) {
                root.position = clamp(
                    self.start-pos + self.mouse-y - self.grab-y,
                    root.bound-min,
                    max(root.bound-min, root.bound-max),
                );
                root.dragged();
            }
        }
    }
}

// ═══════════════════════════════════════════════════════════════════
//  NeonSplitView — backwards-compatible single-slot wrapper
// ═══════════════════════════════════════════════════════════════════
//
//  Kept so existing call sites that import `NeonSplitView` keep
//  compiling. The `@children` slot is the LEADING panel; the
//  trailing area is left empty for the host to fill via siblings
//  outside this component, or just unused. NEW code should prefer
//  composing manually with NeonSplitterHandle (see the file
//  header pattern).
//
// ═══════════════════════════════════════════════════════════════════
export component NeonSplitView inherits Rectangle {
    in-out property <length> split-position: 240px;
    in property <length> min-leading: 80px;
    in property <length> min-trailing: 80px;
    in property <length> divider-thickness: 4px;
    in property <color> divider-color: Theme.border;

    callback split-changed;

    HorizontalLayout {
        spacing: 0px;
        Rectangle {
            width: clamp(
                root.split-position,
                root.min-leading,
                max(root.min-leading,
                    root.width - root.divider-thickness - root.min-trailing),
            );
            clip: true;
            VerticalLayout {
                padding: 0px;
                spacing: 0px;
                @children
            }
        }
        NeonSplitterHandle {
            position <=> root.split-position;
            bound-min: root.min-leading;
            bound-max: max(root.min-leading,
                           root.width - root.divider-thickness - root.min-trailing);
            thickness: root.divider-thickness;
            idle-color: root.divider-color;
            dragged => { root.split-changed(); }
        }
        Rectangle {
            horizontal-stretch: 1;
            clip: true;
        }
    }
}