ui: add terminal window splitting functionality

- Add horizontal and vertical split options to context menu
- Support recursive splitting for complex terminal layouts

Signed-off-by: Jianhui Zhao <zhaojh329@gmail.com>
This commit is contained in:
Jianhui Zhao
2025-07-19 11:49:29 +08:00
parent 1251278902
commit f992856d53
5 changed files with 353 additions and 18 deletions
+193
View File
@@ -0,0 +1,193 @@
<template>
<div class="splitter-panel-root">
<div v-if="!config.panels" :key="config.id" :id="config.id" class="rtty-placeholder"></div>
<div v-else class="rtty-splitter" :class="config.direction">
<div v-for="(panel, index) in config.panels" :key="panel.id || `panel-${index}`" class="splitter-panel" :style="getPanelStyle(index)">
<RttySplitter :config="panel" :devid="devid" @split="handleSplitPanel" @close="handleClosePanel" @resize="handleResize"/>
<div v-if="index < config.panels.length - 1" class="splitter-bar" :class="config.direction" @mousedown="startResize(index, $event)"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
devid: {
type: String,
required: true
},
config: {
type: Object,
required: true
}
})
const emit = defineEmits(['split', 'close', 'resize'])
const panelSizes = ref([])
const isResizing = ref(false)
const resizingIndex = ref(-1)
const startPos = ref(0)
const startSizes = ref([])
const containerRef = ref(null)
function initializePanelSizes() {
if (!props.config.panels) return
if (panelSizes.value.length !== props.config.panels.length) {
const defaultSize = 100 / props.config.panels.length
panelSizes.value = new Array(props.config.panels.length).fill(defaultSize)
}
}
function getPanelStyle(index) {
if (!props.config.panels) return {}
initializePanelSizes()
const size = panelSizes.value[index] || (100 / props.config.panels.length)
if (props.config.direction === 'horizontal') {
return {
width: `${size}%`,
height: '100%',
position: 'relative'
}
} else {
return {
height: `${size}%`,
width: '100%',
position: 'relative'
}
}
}
function startResize(index, event) {
isResizing.value = true
resizingIndex.value = index
startPos.value = props.config.direction === 'horizontal' ? event.clientX : event.clientY
startSizes.value = [...panelSizes.value]
containerRef.value = event.target.closest('.rtty-splitter')
document.addEventListener('mousemove', handleResizeMove)
document.addEventListener('mouseup', stopResize)
event.preventDefault()
document.body.style.userSelect = 'none'
}
function handleResizeMove(event) {
if (!isResizing.value) return
const currentPos = props.config.direction === 'horizontal' ? event.clientX : event.clientY
const delta = currentPos - startPos.value
const container = containerRef.value
if (!container) return
const containerSize = props.config.direction === 'horizontal' ? container.clientWidth : container.clientHeight
const deltaPercent = (delta / containerSize) * 100
const newSizes = [...startSizes.value]
const leftIndex = resizingIndex.value
const rightIndex = leftIndex + 1
const minSize = 5
const maxLeftDecrease = Math.max(0, newSizes[leftIndex] - minSize)
const maxRightDecrease = Math.max(0, newSizes[rightIndex] - minSize)
const actualDelta = Math.max(-maxLeftDecrease, Math.min(maxRightDecrease, deltaPercent))
newSizes[leftIndex] = startSizes.value[leftIndex] + actualDelta
newSizes[rightIndex] = startSizes.value[rightIndex] - actualDelta
panelSizes.value = newSizes
}
function stopResize() {
isResizing.value = false
resizingIndex.value = -1
containerRef.value = null
document.removeEventListener('mousemove', handleResizeMove)
document.removeEventListener('mouseup', stopResize)
document.body.style.userSelect = ''
emit('resize')
}
function handleSplitPanel(panelId, position) {
emit('split', panelId, position)
}
function handleClosePanel(panelId) {
emit('close', panelId)
}
function handleResize() {
emit('resize')
}
</script>
<style scoped>
.splitter-panel-root {
width: 100%;
height: 100%;
}
.rtty-placeholder {
width: 100%;
height: 100%;
}
.rtty-splitter {
width: 100%;
height: 100%;
display: flex;
position: relative;
}
.rtty-splitter.horizontal {
flex-direction: row;
}
.rtty-splitter.vertical {
flex-direction: column;
}
.splitter-panel {
overflow: hidden;
position: relative;
}
.splitter-bar {
position: absolute;
background-color: #dcdfe6;
z-index: 10;
transition: background-color 0.2s;
}
.splitter-bar:hover {
background-color: #409eff;
}
.splitter-bar.horizontal {
top: 0;
right: -2px;
width: 4px;
height: 100%;
cursor: ew-resize;
}
.splitter-bar.vertical {
bottom: -2px;
left: 0;
width: 100%;
height: 4px;
cursor: ns-resize;
}
</style>
+28 -17
View File
@@ -1,5 +1,5 @@
<template>
<div>
<div class="terminal-container">
<div ref="terminal" class="terminal" @contextmenu.prevent="showContextmenu"></div>
<el-dialog v-model="file.modal" :title="$t('Upload file to device')" @close="onUploadDialogClosed" :width="400">
<el-upload :before-upload="beforeUpload" action="#">
@@ -44,7 +44,8 @@ export default {
'Contextmenu': Contextmenu
},
props: {
devid: String
devid: String,
panelId: String
},
data() {
return {
@@ -55,6 +56,11 @@ export default {
{name: 'font', caption: this.$t('Font Size')},
{name: 'upload', caption: this.$t('Upload file') + ' - rtty -R'},
{name: 'download', caption: this.$t('Download file') + ' - rtty -S file'},
{name: 'split-left', caption: this.$t('split-left')},
{name: 'split-right', caption: this.$t('split-right')},
{name: 'split-up', caption: this.$t('split-up')},
{name: 'split-down', caption: this.$t('split-down')},
{name: 'close', caption: this.$t('Close')},
{name: 'about', caption: this.$t('About')}
],
font: {
@@ -71,7 +77,6 @@ export default {
chunks: []
},
disposables: [],
resizeDelay: null,
socket: null,
term: null,
fitAddon: null,
@@ -100,6 +105,16 @@ export default {
this.$message.success(this.$t('Please execute command "rtty -R" in current terminal!'))
} else if (name === 'download') {
this.$message.success(this.$t('Please execute command "rtty -S file" in current terminal!'))
} else if (name === 'split-left') {
this.$emit('split', this.panelId, 'left')
} else if (name === 'split-right') {
this.$emit('split', this.panelId, 'right')
} else if (name === 'split-up') {
this.$emit('split', this.panelId, 'up')
} else if (name === 'split-down') {
this.$emit('split', this.panelId, 'down')
} else if (name === 'close') {
this.$emit('close', this.panelId)
} else if (name === 'about') {
window.open('https://github.com/zhaojh329/rtty')
}
@@ -209,18 +224,13 @@ export default {
this.socket.send(b)
},
fitTerm() {
this.$nextTick(() => {
if (this.resizeDelay)
clearTimeout(this.resizeDelay)
this.resizeDelay = setTimeout(() => {
this.fitAddon.fit()
}, 200)
})
this.$nextTick(() => this.fitAddon.fit())
},
closed() {
if (this.term)
this.term.write('\n\n\r\x1B[1;3;31mConnection is closed.\x1B[0m')
this.dispose()
this.$emit('close', this.panelId)
},
openTerm() {
const term = new Terminal({
@@ -248,12 +258,7 @@ export default {
overlayAddon.show(term.cols + 'x' + term.rows)
}))
window.addEventListener('resize', this.fitTerm)
this.disposables.push({
dispose: () => window.removeEventListener('resize', this.fitTerm)
})
window.addEventListener('rtty-resize', this.fitTerm)
this.fitTerm()
},
dispose() {
@@ -356,6 +361,8 @@ export default {
})
},
unmounted() {
window.removeEventListener('rtty-resize', this.fitTerm)
this.dispose()
if (this.term)
this.term.dispose()
@@ -367,9 +374,13 @@ export default {
</script>
<style scoped>
.terminal-container {
height: 100%;
}
.terminal {
margin: 5px;
height: calc(100vh - 10px);
height: 100%;
}
:deep(.xterm .xterm-viewport) {
+5
View File
@@ -55,6 +55,11 @@
"Font Size": "Font Size",
"Upload file": "Upload file",
"Download file": "Download file",
"split-left": "Split: Left",
"split-right": "Split: Right",
"split-up": "Split: Up",
"split-down": "Split: Down",
"Close": "Close",
"About": "About",
"Please execute command \"rtty -R\" in current terminal!": "Please execute command \"rtty -R\" in current terminal!",
"Please execute command \"rtty -S file\" in current terminal!": "Please execute command \"rtty -S file\" in current terminal!",
+5
View File
@@ -56,6 +56,11 @@
"Font Size": "字体大小",
"Upload file": "上传文件",
"Download file": "下载文件",
"split-left": "拆分窗格: 左",
"split-right": "拆分窗格: 右",
"split-up": "拆分窗格: 上",
"split-down": "拆分窗格: 下",
"Close": "关闭",
"About": "关于",
"Please execute command \"rtty -R\" in current terminal!": "请在当前终端中执行命令 \"rtty -R\"",
"Please execute command \"rtty -S file\" in current terminal!": "请在当前终端中执行命令 \"rtty -S file\"",
+122 -1
View File
@@ -1,9 +1,14 @@
<template>
<RttyTerm :devid="devid"/>
<RttySplitter :devid="devid" :config="rootConfig" @split="handleSplitPanel" @close="handleClosePanel" @resize="handleResize" class="splitter-root"/>
<div id="terminal-pool">
<RttyTerm v-for="id in terms" :key="id" :data-terminal-id="id" :devid="devid" :panel-id="id" @split="handleSplitPanel" @close="handleClosePanel"/>
</div>
</template>
<script setup>
import RttySplitter from '../components/RttySplitter.vue'
import RttyTerm from '../components/RttyTerm.vue'
import { ref, computed, onMounted, nextTick } from 'vue'
defineProps({
devid: {
@@ -12,4 +17,120 @@ defineProps({
}
})
const paneRootID = 'rtty-panel-root'
const rootConfig = ref({
id: paneRootID
})
const terms = computed(() => {
const ids = []
const traverse = (node) => {
if (node.id) {
ids.push(node.id)
} else if (node.panels) {
node.panels.forEach(traverse)
}
}
traverse(rootConfig.value)
return ids
})
function moveTerminalsToPool() {
const pool = document.getElementById('terminal-pool')
terms.value.forEach(termId => {
const term = document.querySelector(`[data-terminal-id="${termId}"]`)
pool.appendChild(term)
})
}
function moveTerminalsToPlaceholders() {
nextTick(() => {
terms.value.forEach(termId => {
const term = document.querySelector(`[data-terminal-id="${termId}"]`)
const placeholder = document.getElementById(termId)
placeholder.appendChild(term)
})
setTimeout(() => dispatchEventRttyResize(), 100)
})
}
onMounted(() => moveTerminalsToPlaceholders())
function handleResize() {
dispatchEventRttyResize()
}
window.addEventListener('resize', handleResize)
function dispatchEventRttyResize() {
window.dispatchEvent(new CustomEvent('rtty-resize'))
}
function handleClosePanel(panelId) {
moveTerminalsToPool()
deletePanel(rootConfig.value, panelId, 0)
moveTerminalsToPlaceholders()
}
function deletePanel(config, panelId, index, parent) {
if (parent && config.id === panelId) {
parent.panels.splice(index, 1)
if (parent.panels.length === 1) {
parent.id = parent.panels[0].id
delete parent.direction
delete parent.panels
}
return true
} else if (config.panels) {
return config.panels.some((panel, i) => deletePanel(panel, panelId, i, config))
}
return false
}
function handleSplitPanel(panelId, direction) {
moveTerminalsToPool()
splitPanel(rootConfig.value, panelId, 0, direction)
moveTerminalsToPlaceholders()
}
function splitPanel(config, panelId, index, position, parent) {
if (config.id === panelId) {
const direction = (position === 'left' || position === 'right') ? 'horizontal' : 'vertical'
const newId = 'panel-' + Math.random().toString(36).substring(2, 10)
if (parent && (parent.direction === direction || parent.panels.length < 2)) {
parent.direction = direction
if (position === 'right' || position === 'down')
index++
parent.panels.splice(index, 0, { id: newId })
} else {
if (position === 'right' || position === 'down') {
config.panels = [
{ id: config.id },
{ id: newId }
]
} else {
config.panels = [
{ id: newId },
{ id: config.id }
]
}
config.direction = direction
delete config.id
}
return true
} else if (config.panels) {
return config.panels.some((panel, i) => splitPanel(panel, panelId, i, position, config))
}
return false
}
</script>
<style scoped>
.splitter-root {
height: 100vh;
}
</style>