convert slash autocomplete to inline text expansion with LCP and arg completion
This commit is contained in:
parent
cb5ddc6e1a
commit
2af7d65f4e
346
gui/main.qml
346
gui/main.qml
|
|
@ -62,9 +62,7 @@ ApplicationWindow {
|
||||||
Shortcut {
|
Shortcut {
|
||||||
sequence: "Escape"
|
sequence: "Escape"
|
||||||
onActivated: {
|
onActivated: {
|
||||||
if (completionPopup.visible) {
|
if (ollie.activeState !== "idle") {
|
||||||
completionPopup.close()
|
|
||||||
} else if (ollie.activeState !== "idle") {
|
|
||||||
ollie.interrupt()
|
ollie.interrupt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,175 +80,95 @@ ApplicationWindow {
|
||||||
{ name: "help", desc: "Show help" }
|
{ name: "help", desc: "Show help" }
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- Completion popup ---
|
// --- Inline autocomplete state machine ---
|
||||||
Popup {
|
// States: none -> start -> autocompleting -> done (tab/esc/no-match)
|
||||||
id: completionPopup
|
property string _acBase: "" // text before the slash command (includes "/")
|
||||||
x: 80
|
property string _acTyped: "" // what user typed after /
|
||||||
y: parent.height - 280
|
property var _acMatches: [] // matching options
|
||||||
width: Math.min(360, parent.width - 160)
|
property bool _acActive: false // in autocomplete mode
|
||||||
height: Math.min(200, visibleMatches * 28 + 32)
|
property int _acMatchIndex: 0 // which match is currently shown
|
||||||
modal: false
|
property string _acType: "cmd" // "cmd", "model", or "backend"
|
||||||
focus: true
|
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
|
|
||||||
|
|
||||||
background: Rectangle {
|
function acFindMatches(prefix, type) {
|
||||||
radius: 6
|
if (type === "model") {
|
||||||
color: theme.isDark ? "#1e1e1e" : "#ffffff"
|
var models = ollie.getAvailableModels(ollie.currentBackend || "")
|
||||||
border.color: theme.midColor
|
var m = []
|
||||||
border.width: 1
|
for (var i = 0; i < models.length; ++i) {
|
||||||
}
|
if (models[i].toLowerCase().indexOf(prefix.toLowerCase()) >= 0)
|
||||||
|
m.push(models[i])
|
||||||
ListView {
|
|
||||||
id: completionList
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 4
|
|
||||||
model: completionModel
|
|
||||||
currentIndex: -1
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
delegate: ItemDelegate {
|
|
||||||
width: completionList.width
|
|
||||||
height: 28
|
|
||||||
highlighted: index === completionList.currentIndex
|
|
||||||
|
|
||||||
contentItem: RowLayout {
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: model.name
|
|
||||||
font.bold: true
|
|
||||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
|
|
||||||
color: theme.textColor
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: model.desc || ""
|
|
||||||
opacity: 0.5
|
|
||||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.8 : 10
|
|
||||||
color: theme.textColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onClicked: {
|
|
||||||
completionList.currentIndex = index
|
|
||||||
acceptCompletion()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return m
|
||||||
Keys.onPressed: function(event) {
|
} else if (type === "backend") {
|
||||||
if (event.key === Qt.Key_Down) {
|
var bks = ollie.availableBackends
|
||||||
if (completionList.currentIndex < completionList.count - 1) {
|
var m = []
|
||||||
completionList.currentIndex++
|
for (var j = 0; j < bks.length; ++j) {
|
||||||
event.accepted = true
|
if (bks[j].toLowerCase().indexOf(prefix.toLowerCase()) >= 0)
|
||||||
}
|
m.push(bks[j])
|
||||||
} else if (event.key === Qt.Key_Up) {
|
|
||||||
if (completionList.currentIndex > 0) {
|
|
||||||
completionList.currentIndex--
|
|
||||||
event.accepted = true
|
|
||||||
}
|
|
||||||
} else if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
|
|
||||||
if (completionList.currentIndex >= 0) {
|
|
||||||
acceptCompletion()
|
|
||||||
event.accepted = true
|
|
||||||
}
|
|
||||||
} else if (event.key === Qt.Key_Escape) {
|
|
||||||
completionPopup.close()
|
|
||||||
event.accepted = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return m
|
||||||
Keys.onTabPressed: {
|
} else {
|
||||||
if (completionList.currentIndex >= 0) {
|
var m = []
|
||||||
acceptCompletion()
|
for (var k = 0; k < root.slashCommands.length; ++k) {
|
||||||
event.accepted = true
|
if (root.slashCommands[k].name.indexOf(prefix) >= 0)
|
||||||
}
|
m.push(root.slashCommands[k].name)
|
||||||
}
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
ListModel {
|
|
||||||
id: completionModel
|
|
||||||
}
|
|
||||||
|
|
||||||
function open(prefix) {
|
|
||||||
updateCompletions(prefix)
|
|
||||||
visible = true
|
|
||||||
promptInput.forceActiveFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
visible = false
|
|
||||||
completionModel.clear()
|
|
||||||
completionList.currentIndex = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCompletions(prefix) {
|
|
||||||
completionModel.clear()
|
|
||||||
var parts = prefix.split(" ")
|
|
||||||
var cmd = parts[0].toLowerCase()
|
|
||||||
var arg = parts.length > 1 ? parts.slice(1).join(" ") : ""
|
|
||||||
|
|
||||||
// If there's an argument part after a known command, show argument completions
|
|
||||||
if (arg !== "") {
|
|
||||||
if (cmd === "model") {
|
|
||||||
var models = ollie.getAvailableModels(ollie.availableBackends[currentBackendIndex] || "")
|
|
||||||
for (var i = 0; i < models.length; ++i) {
|
|
||||||
if (models[i].toLowerCase().indexOf(arg.toLowerCase()) >= 0) {
|
|
||||||
completionModel.append({ name: models[i], desc: "", isArg: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (cmd === "backend") {
|
|
||||||
var bks = ollie.availableBackends
|
|
||||||
for (var j = 0; j < bks.length; ++j) {
|
|
||||||
if (bks[j].toLowerCase().indexOf(arg.toLowerCase()) >= 0) {
|
|
||||||
completionModel.append({ name: bks[j], desc: "", isArg: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Show command completions filtered by prefix
|
|
||||||
for (var k = 0; k < root.slashCommands.length; ++k) {
|
|
||||||
var sc = root.slashCommands[k]
|
|
||||||
if (sc.name.indexOf(cmd) >= 0) {
|
|
||||||
completionModel.append({ name: sc.name, desc: sc.desc, isArg: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completionModel.count > 0) {
|
|
||||||
completionList.currentIndex = 0
|
|
||||||
visible = true
|
|
||||||
} else {
|
|
||||||
visible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptCompletion() {
|
|
||||||
if (completionList.currentIndex < 0 || completionList.currentIndex >= completionModel.count) return
|
|
||||||
var item = completionModel.get(completionList.currentIndex)
|
|
||||||
var oldText = promptInput.text
|
|
||||||
var slashPos = oldText.lastIndexOf("/")
|
|
||||||
if (slashPos < 0) return
|
|
||||||
|
|
||||||
var beforeSlash = oldText.substring(0, slashPos)
|
|
||||||
var afterSlash = oldText.substring(slashPos + 1)
|
|
||||||
var parts = afterSlash.split(" ")
|
|
||||||
|
|
||||||
if (item.isArg) {
|
|
||||||
// Replace the argument part
|
|
||||||
parts[parts.length - 1] = item.name
|
|
||||||
promptInput.text = beforeSlash + "/" + parts.join(" ")
|
|
||||||
} else {
|
|
||||||
// Replace command with command + space
|
|
||||||
promptInput.text = beforeSlash + "/" + item.name + " "
|
|
||||||
}
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
property int visibleMatches: completionModel.count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acLCP(arr) {
|
||||||
|
if (arr.length === 0) return ""
|
||||||
|
if (arr.length === 1) return arr[0]
|
||||||
|
var lcp = arr[0]
|
||||||
|
for (var i = 1; i < arr.length; ++i) {
|
||||||
|
while (!arr[i].startsWith(lcp)) {
|
||||||
|
lcp = lcp.substring(0, lcp.length - 1)
|
||||||
|
if (lcp === "") return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lcp
|
||||||
|
}
|
||||||
|
|
||||||
|
function acEnter(prefix, type) {
|
||||||
|
_acActive = true
|
||||||
|
_acTyped = prefix
|
||||||
|
_acMatches = acFindMatches(prefix, type)
|
||||||
|
_acMatchIndex = 0
|
||||||
|
if (_acMatches.length > 0) {
|
||||||
|
var lcp = acLCP(_acMatches)
|
||||||
|
if (lcp.length > prefix.length) {
|
||||||
|
text = _acBase + lcp
|
||||||
|
cursorPosition = text.length
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No options — transition to done immediately
|
||||||
|
_acActive = false
|
||||||
|
_acBase = ""
|
||||||
|
_acTyped = ""
|
||||||
|
_acMatches = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acCycle() {
|
||||||
|
if (!_acActive || _acMatches.length <= 1) return
|
||||||
|
_acMatchIndex = (_acMatchIndex + 1) % _acMatches.length
|
||||||
|
var parts = text.substring(text.lastIndexOf("/") + 1).split(" ")
|
||||||
|
parts[0] = _acMatches[_acMatchIndex]
|
||||||
|
text = _acBase + "/" + parts.join(" ")
|
||||||
|
cursorPosition = text.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function acCancel() {
|
||||||
|
if (!_acActive) return
|
||||||
|
text = _acBase + _acTyped
|
||||||
|
cursorPosition = text.length
|
||||||
|
_acActive = false
|
||||||
|
_acBase = ""
|
||||||
|
_acTyped = ""
|
||||||
|
_acMatches = []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Deferred scroll timer — fires once after model changes settle
|
// Deferred scroll timer — fires once after model changes settle
|
||||||
Timer {
|
Timer {
|
||||||
id: scrollTimer
|
id: scrollTimer
|
||||||
|
|
@ -928,6 +846,8 @@ ApplicationWindow {
|
||||||
enabled: ollie.activeSessionId !== ""
|
enabled: ollie.activeSessionId !== ""
|
||||||
wrapMode: TextEdit.Wrap
|
wrapMode: TextEdit.Wrap
|
||||||
|
|
||||||
|
property string _lastText: ""
|
||||||
|
property int _lastCursorPos: 0
|
||||||
property string _lastText: ""
|
property string _lastText: ""
|
||||||
property int _lastCursorPos: 0
|
property int _lastCursorPos: 0
|
||||||
|
|
||||||
|
|
@ -936,20 +856,49 @@ ApplicationWindow {
|
||||||
var newText = text
|
var newText = text
|
||||||
var cursorPos = cursorPosition
|
var cursorPos = cursorPosition
|
||||||
|
|
||||||
// Detect slash prefix (text after last "/" up to cursor)
|
// Check if we just completed a command name and added a space
|
||||||
if (newText.indexOf("/") >= 0) {
|
if (_acActive && _acMatches.length > 0) {
|
||||||
|
var afterSlash = newText.substring(newText.lastIndexOf("/") + 1)
|
||||||
|
var parts = afterSlash.split(" ")
|
||||||
|
// If first part matches a known command exactly and there's a second part
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
var cmdName = parts[0].toLowerCase()
|
||||||
|
if (cmdName === "model") {
|
||||||
|
// Switch to model arg completion — update base to include command
|
||||||
|
_acBase = newText.substring(0, newText.lastIndexOf("/") + 1) + parts[0] + " "
|
||||||
|
acEnter(parts.slice(1).join(" "), "model")
|
||||||
|
return
|
||||||
|
} else if (cmdName === "backend") {
|
||||||
|
// Switch to backend arg completion
|
||||||
|
_acBase = newText.substring(0, newText.lastIndexOf("/") + 1) + parts[0] + " "
|
||||||
|
acEnter(parts.slice(1).join(" "), "backend")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_acActive && newText.indexOf("/") >= 0) {
|
||||||
var slashPos = newText.lastIndexOf("/")
|
var slashPos = newText.lastIndexOf("/")
|
||||||
if (slashPos < cursorPos) {
|
if (slashPos < cursorPos) {
|
||||||
var prefix = newText.substring(slashPos + 1, cursorPos)
|
var prefix = newText.substring(slashPos + 1, cursorPos)
|
||||||
if (prefix !== oldText.substring(oldText.lastIndexOf("/"), cursorPos) ||
|
if (prefix.length > 0 && prefix !== " ") {
|
||||||
oldText !== _lastText) {
|
_acBase = newText.substring(0, slashPos + 1)
|
||||||
completionPopup.open(prefix)
|
acEnter(prefix, "cmd")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
completionPopup.close()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (_acActive) {
|
||||||
completionPopup.close()
|
// User is typing inside autocomplete — re-enter with updated prefix
|
||||||
|
var slashPos = newText.lastIndexOf("/")
|
||||||
|
var prefix = newText.substring(slashPos + 1, cursorPos)
|
||||||
|
if (prefix.length > 0 && prefix !== " ") {
|
||||||
|
_acBase = newText.substring(0, slashPos + 1)
|
||||||
|
acEnter(prefix, _acType)
|
||||||
|
} else {
|
||||||
|
acCancel()
|
||||||
|
}
|
||||||
|
} else if (newText.indexOf("/") < 0) {
|
||||||
|
// No slash at all — cancel any lingering state
|
||||||
|
acCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastText = newText
|
_lastText = newText
|
||||||
|
|
@ -957,41 +906,47 @@ ApplicationWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
onCursorPositionChanged: {
|
onCursorPositionChanged: {
|
||||||
var newText = text
|
if (!_acActive && text.indexOf("/") >= 0) {
|
||||||
if (newText.indexOf("/") >= 0) {
|
var slashPos = text.lastIndexOf("/")
|
||||||
var slashPos = newText.lastIndexOf("/")
|
|
||||||
if (slashPos < cursorPosition) {
|
if (slashPos < cursorPosition) {
|
||||||
var prefix = newText.substring(slashPos + 1, cursorPosition)
|
var prefix = text.substring(slashPos + 1, cursorPosition)
|
||||||
completionPopup.open(prefix)
|
if (prefix.length > 0 && prefix !== " ") {
|
||||||
} else {
|
_acBase = text.substring(0, slashPos + 1)
|
||||||
completionPopup.close()
|
acEnter(prefix, "cmd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (_acActive) {
|
||||||
|
var slashPos = text.lastIndexOf("/")
|
||||||
|
var prefix = text.substring(slashPos + 1, cursorPosition)
|
||||||
|
if (prefix.length > 0 && prefix !== " ") {
|
||||||
|
_acBase = text.substring(0, slashPos + 1)
|
||||||
|
acEnter(prefix, _acType)
|
||||||
|
} else {
|
||||||
|
acCancel()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
completionPopup.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: function(event) {
|
Keys.onPressed: function(event) {
|
||||||
if (completionPopup.visible) {
|
if (_acActive) {
|
||||||
if (event.key === Qt.Key_Tab) {
|
if (event.key === Qt.Key_Tab) {
|
||||||
completionPopup.acceptCompletion()
|
acCycle()
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
} else if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
|
|
||||||
if (completionPopup.completionList.currentIndex >= 0) {
|
|
||||||
completionPopup.acceptCompletion()
|
|
||||||
event.accepted = true
|
|
||||||
}
|
|
||||||
} else if (event.key === Qt.Key_Escape) {
|
} else if (event.key === Qt.Key_Escape) {
|
||||||
completionPopup.close()
|
acCancel()
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
} else if (event.key === Qt.Key_Down) {
|
} else if (event.key === Qt.Key_Down) {
|
||||||
if (completionPopup.completionList.currentIndex < completionPopup.completionList.count - 1) {
|
if (_acMatches.length > 1) {
|
||||||
completionPopup.completionList.currentIndex++
|
acCycle()
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
}
|
}
|
||||||
} else if (event.key === Qt.Key_Up) {
|
} else if (event.key === Qt.Key_Up) {
|
||||||
if (completionPopup.completionList.currentIndex > 0) {
|
if (_acMatches.length > 1) {
|
||||||
completionPopup.completionList.currentIndex--
|
_acMatchIndex = (_acMatchIndex - 1 + _acMatches.length) % _acMatches.length
|
||||||
|
var parts = text.substring(text.lastIndexOf("/") + 1).split(" ")
|
||||||
|
parts[0] = _acMatches[_acMatchIndex]
|
||||||
|
text = _acBase + "/" + parts.join(" ")
|
||||||
|
cursorPosition = text.length
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1014,4 +969,3 @@ ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Reference in New Issue