This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/gui/main.qml

710 lines
25 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "emojis.js" as Emojis
ApplicationWindow {
id: root
title: dbus.activeCwd ? "Ollie — " + dbus.activeCwd : "Ollie"
width: 900
height: 600
visible: true
onClosing: function(close) {
if (theme.closeToTray && systemTrayAvailable) {
close.accepted = false
root.hide()
}
}
color: palette.window
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
palette {
window: theme.windowColor
windowText: theme.windowTextColor
base: theme.baseColor
alternateBase: theme.alternateBaseColor
text: theme.textColor
button: theme.buttonColor
buttonText: theme.buttonTextColor
highlight: theme.highlightColor
highlightedText: theme.highlightedTextColor
toolTipBase: theme.tooltipBaseColor
toolTipText: theme.tooltipTextColor
link: theme.linkColor
mid: theme.midColor
dark: theme.darkColor
light: theme.lightColor
shadow: theme.shadowColor
placeholderText: theme.placeholderTextColor
}
// --- Settings Dialog ---
Dialog {
id: settingsDialog
title: "Settings"
anchors.centerIn: parent
width: 380
modal: true
standardButtons: Dialog.Close
contentItem: ColumnLayout {
spacing: 16
Layout.margins: 16
Label {
text: "Color Scheme"
font.bold: true
}
ComboBox {
id: schemeCombo
Layout.fillWidth: true
model: ["System"].concat(theme.availableSchemes)
currentIndex: {
if (theme.mode === "system") return 0
let idx = theme.availableSchemes.indexOf(theme.mode)
return idx >= 0 ? idx + 1 : 0
}
onActivated: {
if (currentIndex === 0) {
theme.mode = "system"
} else {
theme.mode = theme.availableSchemes[currentIndex - 1]
}
}
}
Label {
text: theme.isDark ? "Current: dark" : "Current: light"
opacity: 0.5
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
// --- Font section ---
Rectangle {
Layout.fillWidth: true
height: 1
color: palette.mid
Layout.topMargin: 8
}
Label {
text: "Font"
font.bold: true
}
GridLayout {
columns: 2
columnSpacing: 12
rowSpacing: 8
Layout.fillWidth: true
Label { text: "Family:" }
ComboBox {
id: fontFamilyCombo
Layout.fillWidth: true
model: theme.availableFamilies
editable: true
currentIndex: {
let fam = theme.fontFamily || Qt.application.font.family
let idx = model.indexOf(fam)
return idx >= 0 ? idx : 0
}
onActivated: theme.fontFamily = currentText
onAccepted: theme.fontFamily = editText
}
Label { text: "Style:" }
ComboBox {
id: fontStyleCombo
Layout.fillWidth: true
model: theme.availableStyles
currentIndex: {
let st = theme.fontStyle
let idx = model.indexOf(st)
return idx >= 0 ? idx : 0
}
onActivated: theme.fontStyle = currentText
}
Label { text: "Size:" }
SpinBox {
id: fontSizeSpin
from: 6
to: 72
value: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
onValueModified: theme.fontSize = value
}
}
Button {
text: "Reset to system font"
onClicked: theme.resetFont()
}
// --- Close to tray section ---
Rectangle {
Layout.fillWidth: true
height: 1
color: palette.mid
Layout.topMargin: 8
}
Label {
text: "System Tray"
font.bold: true
}
CheckBox {
text: "Close to tray instead of quitting"
checked: theme.closeToTray
onToggled: theme.closeToTray = checked
enabled: systemTrayAvailable
}
Label {
text: systemTrayAvailable
? "Window will hide to the system tray when closed."
: "System tray is not available."
opacity: 0.5
font.pixelSize: Qt.application.font.pixelSize * 0.85
wrapMode: Text.Wrap
Layout.fillWidth: true
visible: theme.closeToTray || !systemTrayAvailable
}
}
}
function openNewSessionDialog() {
let path = theme.chooseFolder("Choose working directory for new session")
if (path !== "")
dbus.createSession(path)
}
function openCwdDialog() {
let path = theme.chooseFolder("Change working directory",
dbus.activeCwd || "")
if (path !== "")
dbus.setConfig(dbus.activeSessionId, "cwd", path)
}
// --- Session Graph Window ---
Window {
id: graphWindow
title: "Ollie — Session Topology"
width: 700
height: 500
visible: false
color: palette.window
SessionGraph {
id: graphView
anchors.fill: parent
sessions: dbus.sessions
peerListFn: function(sid) { return dbus.peerList(sid) }
}
}
SplitView {
anchors.fill: parent
orientation: Qt.Horizontal
// --- Session sidebar ---
Pane {
SplitView.preferredWidth: 220
SplitView.minimumWidth: 160
padding: 0
ColumnLayout {
anchors.fill: parent
spacing: 0
// Toolbar
RowLayout {
Layout.fillWidth: true
Layout.margins: 6
Label {
text: "Sessions"
font.bold: true
Layout.fillWidth: true
}
ToolButton {
icon.name: "list-add"
onClicked: root.openNewSessionDialog()
ToolTip.text: "New session"
ToolTip.visible: hovered
}
ToolButton {
icon.name: "configure"
onClicked: settingsDialog.open()
ToolTip.text: "Settings"
ToolTip.visible: hovered
}
ToolButton {
icon.name: "network-workgroup"
onClicked: graphWindow.visible = !graphWindow.visible
ToolTip.text: "Session topology"
ToolTip.visible: hovered
}
}
// Session list
ListView {
id: sessionList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: dbus.sessions
delegate: ItemDelegate {
id: sessionDelegate
width: sessionList.width
highlighted: modelData.id === dbus.activeSessionId
property bool renaming: false
property string sessionState: modelData.state
Connections {
target: dbus
function onSessionStateUpdated(sessionId, newState) {
if (sessionId === modelData.id)
sessionDelegate.sessionState = newState
}
}
background: Rectangle {
color: sessionDelegate.highlighted
? palette.highlight : "transparent"
radius: 4
}
contentItem: RowLayout {
spacing: 6
Rectangle {
width: 8
height: 8
radius: 4
color: {
let state = sessionDelegate.sessionState
if (state === "idle") return "#4caf50"
if (state.startsWith("calling")) return "#ff9800"
return "#2196f3"
}
}
Label {
id: sessionLabel
text: modelData.id
Layout.fillWidth: true
elide: Text.ElideRight
font.family: "monospace"
color: sessionDelegate.highlighted
? palette.highlightedText : palette.text
visible: !sessionDelegate.renaming
}
TextField {
id: renameField
Layout.fillWidth: true
font.family: "monospace"
visible: sessionDelegate.renaming
onAccepted: {
let newName = text.trim()
if (newName !== "" && newName !== modelData.id) {
dbus.renameSession(modelData.id, newName)
}
sessionDelegate.renaming = false
}
Keys.onEscapePressed: sessionDelegate.renaming = false
onVisibleChanged: {
if (visible) {
text = modelData.id
selectAll()
forceActiveFocus()
}
}
onActiveFocusChanged: {
if (!activeFocus && sessionDelegate.renaming) {
sessionDelegate.renaming = false
}
}
}
Label {
text: modelData.agent
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.85
color: sessionDelegate.highlighted
? palette.highlightedText : palette.text
visible: !sessionDelegate.renaming
}
ToolButton {
icon.name: "process-stop"
implicitWidth: 24
implicitHeight: 24
visible: !sessionDelegate.renaming
onClicked: dbus.killSession(modelData.id)
ToolTip.text: "Kill session"
ToolTip.visible: hovered
}
}
onClicked: dbus.activeSessionId = modelData.id
onDoubleClicked: sessionDelegate.renaming = true
}
}
}
}
// --- Main content: tiling pane area ---
Pane {
id: mainContent
SplitView.fillWidth: true
padding: 0
property int focusedPaneIndex: 0
property var panes: []
property int paneCount: 0
// BSP tree: each node is {pane: item} or {split: 'h'|'v', left: node, right: node}
property var bspTree: null
property real gap: 2
function layoutTree(node, x, y, w, h) {
if (!node) return
if (node.pane) {
node.pane.x = x
node.pane.y = y
node.pane.width = w
node.pane.height = h
} else if (node.split) {
if (node.split === 'h') {
let lw = (w - gap) / 2
layoutTree(node.left, x, y, lw, h)
layoutTree(node.right, x + lw + gap, y, w - lw - gap, h)
} else {
let th = (h - gap) / 2
layoutTree(node.left, x, y, w, th)
layoutTree(node.right, x, y + th + gap, w, h - th - gap)
}
}
}
function relayout() {
if (bspTree)
layoutTree(bspTree, 0, 0, width, height)
}
// Find and replace a leaf node in the tree
function splitNode(tree, targetPane, newPane, direction) {
if (!tree) return null
if (tree.pane === targetPane) {
return {split: direction, left: {pane: targetPane}, right: {pane: newPane}}
}
if (tree.split) {
let newLeft = splitNode(tree.left, targetPane, newPane, direction)
if (newLeft) { tree.left = newLeft; return tree }
let newRight = splitNode(tree.right, targetPane, newPane, direction)
if (newRight) { tree.right = newRight; return tree }
}
return null
}
// Remove a pane from the tree, collapsing its parent split
function removeNode(tree, targetPane) {
if (!tree) return null
if (tree.pane === targetPane) return null // found it
if (tree.split) {
if (tree.left && tree.left.pane === targetPane) return tree.right
if (tree.right && tree.right.pane === targetPane) return tree.left
let newLeft = removeNode(tree.left, targetPane)
if (newLeft !== tree.left) { tree.left = newLeft; return tree }
let newRight = removeNode(tree.right, targetPane)
if (newRight !== tree.right) { tree.right = newRight; return tree }
}
return tree
}
onWidthChanged: relayout()
onHeightChanged: relayout()
onPaneCountChanged: relayout()
// Container for panes (absolute positioning)
Item {
id: paneContainer
anchors.fill: parent
ChatPane {
id: initialPane
sessionId: ""
Component.onCompleted: {
sessionId = dbus.activeSessionId
mainContent.panes = [initialPane]
mainContent.paneCount = 1
mainContent.bspTree = {pane: initialPane}
mainContent.relayout()
}
isFocused: mainContent.focusedPaneIndex === 0
onActiveFocusChanged: if (activeFocus) mainContent.focusedPaneIndex = 0
onEndedDrag: function(gx, gy) { root.handlePaneSwap(initialPane, gx, gy) }
}
}
}
}
// --- Split pane shortcuts ---
Shortcut {
sequence: "Ctrl+\\"
onActivated: {
let comp = Qt.createComponent("ChatPane.qml")
if (comp.status !== Component.Ready) {
console.warn("ChatPane.qml:", comp.errorString())
return
}
let focusedPane = mainContent.panes[mainContent.focusedPaneIndex]
if (!focusedPane) return
let pane = comp.createObject(paneContainer, {
sessionId: dbus.activeSessionId,
isFocused: false
})
if (pane) {
// Split direction: alternate based on focused pane's aspect ratio
let dir = focusedPane.width >= focusedPane.height ? 'h' : 'v'
mainContent.bspTree = mainContent.splitNode(mainContent.bspTree, focusedPane, pane, dir)
let panes = mainContent.panes
let insertAt = mainContent.focusedPaneIndex + 1
panes.splice(insertAt, 0, pane)
mainContent.panes = panes
mainContent.paneCount = panes.length
mainContent.focusedPaneIndex = insertAt
mainContent.relayout()
pane.activeFocusChanged.connect(function() {
if (pane.activeFocus) {
mainContent.focusedPaneIndex = mainContent.panes.indexOf(pane)
}
})
pane.endedDrag.connect(function(gx, gy) {
root.handlePaneSwap(pane, gx, gy)
})
}
}
}
Shortcut {
sequence: "Ctrl+W"
onActivated: {
if (mainContent.panes.length <= 1) return
let idx = mainContent.focusedPaneIndex
if (idx < 0 || idx >= mainContent.panes.length)
idx = mainContent.panes.length - 1
let pane = mainContent.panes[idx]
mainContent.bspTree = mainContent.removeNode(mainContent.bspTree, pane)
let panes = mainContent.panes
panes.splice(idx, 1)
mainContent.panes = panes
mainContent.paneCount = panes.length
pane.destroy()
mainContent.focusedPaneIndex = Math.min(idx, panes.length - 1)
mainContent.relayout()
}
}
// Handle pane swap: find target pane at drop coordinates (root-window coords)
function handlePaneSwap(sourcePane, rootX, rootY) {
for (let i = 0; i < mainContent.panes.length; i++) {
let p = mainContent.panes[i]
if (p === sourcePane) continue
let localPt = p.mapFromItem(null, rootX, rootY)
if (localPt.x >= 0 && localPt.x <= p.width &&
localPt.y >= 0 && localPt.y <= p.height) {
// Swap sessions
let tmp = sourcePane.sessionId
sourcePane.sessionId = p.sessionId
p.sessionId = tmp
return
}
}
}
// Sidebar click updates focused pane's session
Connections {
target: dbus
function onActiveSessionIdChanged() {
let idx = mainContent.focusedPaneIndex
if (idx >= 0 && idx < mainContent.panes.length) {
mainContent.panes[idx].sessionId = dbus.activeSessionId
}
}
}
// --- Process Manager Window ---
Window {
id: processManagerWindow
title: "Ollie — Detached Processes"
width: 600
height: 400
color: palette.window
property var processes: []
property int selectedPid: -1
property string selectedOutput: ""
Timer {
id: procRefreshTimer
interval: 1500
running: processManagerWindow.visible
repeat: true
triggeredOnStart: true
onTriggered: {
if (dbus.activeSessionId === "") return
processManagerWindow.processes = dbus.listDetached(dbus.activeSessionId)
if (processManagerWindow.selectedPid > 0) {
processManagerWindow.selectedOutput =
dbus.getDetachedOutput(dbus.activeSessionId, processManagerWindow.selectedPid)
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
Label {
text: "Session: " + (dbus.activeSessionId || "none")
font.bold: true
}
// Process list
ListView {
id: procList
Layout.fillWidth: true
Layout.preferredHeight: 140
clip: true
model: processManagerWindow.processes
delegate: ItemDelegate {
width: procList.width
highlighted: modelData.pid === processManagerWindow.selectedPid
contentItem: RowLayout {
spacing: 8
Rectangle {
width: 8; height: 8; radius: 4
color: modelData.status === "running" ? "#4caf50" : "#9e9e9e"
}
Label {
text: modelData.command
Layout.fillWidth: true
elide: Text.ElideRight
font.family: "monospace"
}
Label {
text: "pid " + modelData.pid
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
Label {
text: modelData.status
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
Button {
text: "Term"
visible: modelData.status === "running"
onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "TERM")
}
Button {
text: "Kill"
visible: modelData.status === "running"
onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "KILL")
}
Button {
text: "Dismiss"
visible: modelData.status !== "running"
onClicked: dbus.dismissDetached(dbus.activeSessionId, modelData.pid)
}
}
onClicked: {
processManagerWindow.selectedPid = modelData.pid
processManagerWindow.selectedOutput =
dbus.getDetachedOutput(dbus.activeSessionId, modelData.pid)
}
}
}
// Separator
Rectangle {
Layout.fillWidth: true
height: 1
color: palette.mid
}
// Output tail
Label {
text: processManagerWindow.selectedPid > 0
? "——— Output: pid " + processManagerWindow.selectedPid + " ———"
: "Select a process to view output"
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
Flickable {
id: outputFlick
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
contentWidth: width
contentHeight: outputArea.implicitHeight
boundsBehavior: Flickable.StopAtBounds
property bool atBottom: true
onContentYChanged: {
let bottom = contentHeight - height
atBottom = (bottom <= 0 || contentY >= bottom - 4)
}
onContentHeightChanged: {
if (atBottom) {
contentY = Math.max(0, contentHeight - height)
}
}
TextArea {
id: outputArea
width: outputFlick.width
readOnly: true
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.9
text: processManagerWindow.selectedOutput
wrapMode: TextEdit.Wrap
background: null
}
}
}
}
}