niuma-ui 1.1.3 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,15 +26,26 @@ import {
26
26
  resolveTerminalTheme,
27
27
  terminalShortcutLabel,
28
28
  type RsTerminalAction,
29
+ type RsTerminalExpose,
30
+ type RsTerminalGeometry,
29
31
  type RsTerminalThemeMode,
30
32
  } from './terminal-utils'
31
33
  import {
32
34
  attachWheelScrollGuard,
35
+ isAlternateTerminalBuffer,
33
36
  type RsTerminalWheelScrollModifier,
34
37
  } from './terminal-wheel'
35
38
 
39
+ /** 单元格高度倍率(xterm options.lineHeight)。中文比 Canvas 测的西文高,1 会裁到下一行。 */
36
40
  const TERMINAL_LINE_HEIGHT = 1.2
37
41
 
42
+ /** 令牌缺省字号;仅在挂载前(含 SSR)占位,挂载后由 syncResolvedFontSize 换成实测值。 */
43
+ const FALLBACK_FONT_SIZE_PX = 14
44
+
45
+ // class / style 需要落在 .rs-terminal 上;根节点是 RsContextMenu 的 as-child 触发器,
46
+ // 交给自动透传会落到触发器上,消费方只能在外面再套一层控制尺寸。
47
+ defineOptions({ inheritAttrs: false })
48
+
38
49
  type RsTerminalFontWeight =
39
50
  | 'normal'
40
51
  | 'bold'
@@ -81,7 +92,10 @@ const props = withDefaults(
81
92
  convertEol?: boolean
82
93
  /** 奇数行斑马纹底色,提升长日志可读性 */
83
94
  zebraStripes?: boolean
84
- /** none:滚轮直接滚 scrollback;shift:仅 Shift+滚轮滚历史,普通滚轮发方向键(SSH/TUI 推荐) */
95
+ /**
96
+ * none:滚轮始终翻 scrollback。
97
+ * shift:普通 shell 翻 scrollback;仅 vim/top 等备用屏把滚轮转成方向键。
98
+ */
85
99
  wheelScrollModifier?: RsTerminalWheelScrollModifier
86
100
  /** TUI 全屏刷新时若视口不在底部,自动滚回底部(修复 top 表头丢失) */
87
101
  snapViewportOnTuiWrite?: boolean
@@ -113,6 +127,8 @@ const emit = defineEmits<{
113
127
  action: [action: RsTerminalAction]
114
128
  /** 询问 AI:携带右键菜单打开时快照的选区(避免菜单点击后选区被清空) */
115
129
  askAi: [text: string]
130
+ /** 选区变化,携带当前选中文本(无选区时为空串) */
131
+ selectionChange: [text: string]
116
132
  }>()
117
133
 
118
134
  const { t } = useRsI18n()
@@ -124,6 +140,8 @@ const menuSelectionSnapshot = ref('')
124
140
  const resolvedThemeMode = ref(resolveTerminalTheme(props.themeMode))
125
141
  /** fit 后量一次真实行高,避免亚像素漂移;非每帧更新 */
126
142
  const zebraRowStepPx = ref<number | null>(null)
143
+ /** 令牌字号的实测缓存:读 CSS 变量是 DOM 操作,不能放进 computed */
144
+ const resolvedFontSizePx = ref(props.fontSize ?? FALLBACK_FONT_SIZE_PX)
127
145
 
128
146
  const showLoading = computed(() => !terminalReady.value || props.loading)
129
147
 
@@ -147,11 +165,15 @@ function resolveTerminalFontWeightBold(): RsTerminalFontWeight {
147
165
  return props.fontWeightBold ?? asTerminalFontWeight(readTerminalFontWeightBold(hostEl.value))
148
166
  }
149
167
 
168
+ function syncResolvedFontSize(): void {
169
+ resolvedFontSizePx.value = resolveTerminalFontSize()
170
+ }
171
+
150
172
  const zebraStyle = computed((): Record<string, string> | undefined => {
151
173
  if (!props.zebraStripes) {
152
174
  return undefined
153
175
  }
154
- const fontSize = resolveTerminalFontSize()
176
+ const fontSize = resolvedFontSizePx.value
155
177
  const step = zebraRowStepPx.value ?? fontSize * TERMINAL_LINE_HEIGHT
156
178
  return {
157
179
  '--rs-terminal-font-size': `${fontSize}px`,
@@ -212,6 +234,8 @@ let resizeObserver: ResizeObserver | null = null
212
234
  let themeObserver: MutationObserver | null = null
213
235
  let detachWheelGuard: (() => void) | null = null
214
236
  let lastGeometry = { cols: 0, rows: 0 }
237
+ let fitRaf = 0
238
+ let didInitialFit = false
215
239
 
216
240
  function resolveAllowTransparency(): boolean {
217
241
  return props.allowTransparency || props.zebraStripes
@@ -251,6 +275,7 @@ function attachWheelGuard(): void {
251
275
  detachWheelGuard = attachWheelScrollGuard(hostEl.value, {
252
276
  modifier: () => props.wheelScrollModifier,
253
277
  inputEnabled: () => props.inputEnabled,
278
+ isAlternateBuffer: () => isAlternateTerminalBuffer(terminal),
254
279
  onArrowKeys: (data) => {
255
280
  if (props.inputEnabled) {
256
281
  emit('data', data)
@@ -264,7 +289,7 @@ function refreshResolvedTheme(): void {
264
289
  applyThemeToTerminal()
265
290
  }
266
291
 
267
- function currentGeometry(): { cols: number; rows: number } | null {
292
+ function currentGeometry(): RsTerminalGeometry | null {
268
293
  if (!terminal) {
269
294
  return null
270
295
  }
@@ -300,37 +325,35 @@ async function fit(): Promise<void> {
300
325
  if (clientWidth <= 0 || clientHeight <= 0) {
301
326
  return
302
327
  }
303
- if (typeof document !== 'undefined' && document.fonts?.ready) {
304
- await document.fonts.ready
305
- }
306
- if (!fitAddon || !terminal || !hostEl.value) {
307
- return
308
- }
309
- await nextTick()
310
- if (!fitAddon || !terminal) {
311
- return
312
- }
313
- await rafTwice()
314
- for (let attempt = 0; attempt < 4; attempt += 1) {
315
- if (!fitAddon || !terminal) {
316
- return
317
- }
318
- try {
319
- fitAddon.fit()
320
- } catch {
321
- return
322
- }
323
- const dims = fitAddon.proposeDimensions()
324
- if (dims && dims.cols > 0 && dims.rows > 0) {
325
- break
328
+ if (!didInitialFit) {
329
+ if (typeof document !== 'undefined' && document.fonts?.ready) {
330
+ await document.fonts.ready
326
331
  }
332
+ await nextTick()
327
333
  await rafTwice()
328
334
  }
329
335
  if (!fitAddon || !terminal) {
330
336
  return
331
337
  }
338
+ try {
339
+ fitAddon.fit()
340
+ } catch {
341
+ return
342
+ }
343
+ didInitialFit = true
332
344
  emitResizeIfChanged()
333
345
  syncZebraRowStepFromDom()
346
+ syncResolvedFontSize()
347
+ }
348
+
349
+ function scheduleFit(): void {
350
+ if (fitRaf) {
351
+ return
352
+ }
353
+ fitRaf = requestAnimationFrame(() => {
354
+ fitRaf = 0
355
+ void fit()
356
+ })
334
357
  }
335
358
 
336
359
  function write(data: string): void {
@@ -347,20 +370,25 @@ function write(data: string): void {
347
370
  function clear(): void {
348
371
  terminal?.clear()
349
372
  terminal?.clearSelection()
350
- hasSelection.value = false
373
+ syncSelectionState()
351
374
  }
352
375
 
353
376
  function focus(): void {
354
377
  terminal?.focus()
355
378
  }
356
379
 
380
+ function getSelectionText(): string {
381
+ return String(terminal?.getSelection?.() ?? '')
382
+ }
383
+
357
384
  function syncSelectionState(): void {
358
385
  hasSelection.value = Boolean(terminal?.hasSelection())
386
+ emit('selectionChange', getSelectionText())
359
387
  }
360
388
 
361
389
  /** 优先活选区;若被右键 word-select / 失焦冲掉,则回退菜单快照。 */
362
390
  function resolveMenuSelectionText(): string {
363
- const live = String(terminal?.getSelection?.() ?? '').trim()
391
+ const live = getSelectionText().trim()
364
392
  const snap = menuSelectionSnapshot.value.trim()
365
393
  if (live && (!snap || live.length >= snap.length)) {
366
394
  return live
@@ -398,7 +426,7 @@ async function pasteFromClipboard(): Promise<void> {
398
426
  function onTerminalContextMenu(): void {
399
427
  void beginClipboardPrefetch()
400
428
  // capture 早于 xterm 的 rightClickSelectsWord,先保住用户拖选(vim/less 大段文本)
401
- const existing = String(terminal?.getSelection?.() ?? '').trim()
429
+ const existing = getSelectionText().trim()
402
430
  if (existing) {
403
431
  menuSelectionSnapshot.value = existing
404
432
  hasSelection.value = true
@@ -408,7 +436,7 @@ function onTerminalContextMenu(): void {
408
436
  // xterm 在 target 阶段才 rightClickSelect,延后同步;无先验选区时采用 word-select 结果
409
437
  void nextTick(() => {
410
438
  syncSelectionState()
411
- const after = String(terminal?.getSelection?.() ?? '').trim()
439
+ const after = getSelectionText().trim()
412
440
  // 仅在无快照,或 word-select 反而选出更长文本时更新;避免冲掉用户拖选
413
441
  if (after && (!menuSelectionSnapshot.value || after.length > menuSelectionSnapshot.value.length)) {
414
442
  menuSelectionSnapshot.value = after
@@ -458,8 +486,10 @@ function onContextMenuSelect(key: string): void {
458
486
  void runTerminalAction(key as RsTerminalAction)
459
487
  }
460
488
 
489
+ // xterm 只有一个 handler 槽位且无法卸载,所以恒定挂载、由 handler 内部实时读 props.shortcuts,
490
+ // 这样该 prop 关得掉也开得回来。
461
491
  function attachShortcuts(): void {
462
- if (!terminal || !props.shortcuts) {
492
+ if (!terminal) {
463
493
  return
464
494
  }
465
495
  terminal.attachCustomKeyEventHandler((event) => {
@@ -499,26 +529,73 @@ function attachShortcuts(): void {
499
529
  })
500
530
  }
501
531
 
532
+ // 逐项 watch:改字体会让 xterm 清空 WidthCache 并全量重测,不能被其他 prop 的变化连带触发。
502
533
  watch(
503
- () => [props.cursorBlink, props.fontFamily, props.fontSize, props.fontWeight, props.fontWeightBold, props.allowTransparency, props.convertEol, props.themeMode, props.theme, props.zebraStripes, props.wheelScrollModifier] as const,
534
+ () => [props.fontFamily, props.fontSize, props.fontWeight, props.fontWeightBold] as const,
504
535
  () => {
505
536
  if (!terminal) {
506
537
  return
507
538
  }
508
- refreshResolvedTheme()
509
- terminal.options.cursorBlink = props.cursorBlink
510
539
  terminal.options.fontFamily = resolveTerminalFontFamily()
511
540
  terminal.options.fontSize = resolveTerminalFontSize()
512
541
  terminal.options.fontWeight = resolveTerminalFontWeight()
513
542
  terminal.options.fontWeightBold = resolveTerminalFontWeightBold()
543
+ void fit()
544
+ },
545
+ )
546
+
547
+ watch(
548
+ () => props.cursorBlink,
549
+ (value) => {
550
+ if (terminal) {
551
+ terminal.options.cursorBlink = value
552
+ }
553
+ },
554
+ )
555
+
556
+ watch(
557
+ () => props.convertEol,
558
+ (value) => {
559
+ if (terminal) {
560
+ terminal.options.convertEol = value
561
+ }
562
+ },
563
+ )
564
+
565
+ watch(
566
+ () => props.scrollback,
567
+ (value) => {
568
+ if (terminal) {
569
+ terminal.options.scrollback = value
570
+ }
571
+ },
572
+ )
573
+
574
+ watch(
575
+ () => props.rightClickSelectsWord,
576
+ (value) => {
577
+ if (terminal) {
578
+ terminal.options.rightClickSelectsWord = value
579
+ }
580
+ },
581
+ )
582
+
583
+ watch(
584
+ () => [props.allowTransparency, props.zebraStripes] as const,
585
+ () => {
586
+ if (!terminal) {
587
+ return
588
+ }
514
589
  terminal.options.allowTransparency = resolveAllowTransparency()
515
- terminal.options.convertEol = props.convertEol
516
- attachWheelGuard()
517
590
  void fit()
518
591
  },
519
- { deep: true },
520
592
  )
521
593
 
594
+ // deep 只用于 theme 这一个对象 prop,避免把内联字面量的重建放大成全量重设。
595
+ watch(() => [props.themeMode, props.theme] as const, refreshResolvedTheme, { deep: true })
596
+
597
+ watch(() => props.wheelScrollModifier, attachWheelGuard)
598
+
522
599
  onMounted(async () => {
523
600
  if (!hostEl.value) {
524
601
  return
@@ -531,6 +608,7 @@ onMounted(async () => {
531
608
  fontWeight: resolveTerminalFontWeight(),
532
609
  fontWeightBold: resolveTerminalFontWeightBold(),
533
610
  lineHeight: TERMINAL_LINE_HEIGHT,
611
+ letterSpacing: 0,
534
612
  allowTransparency: resolveAllowTransparency(),
535
613
  drawBoldTextInBrightColors: true,
536
614
  scrollback: props.scrollback,
@@ -557,7 +635,7 @@ onMounted(async () => {
557
635
  emit('ready')
558
636
 
559
637
  resizeObserver = new ResizeObserver(() => {
560
- void fit()
638
+ scheduleFit()
561
639
  })
562
640
  resizeObserver.observe(hostEl.value)
563
641
 
@@ -579,12 +657,17 @@ onBeforeUnmount(() => {
579
657
  resizeObserver = null
580
658
  detachWheelGuard?.()
581
659
  detachWheelGuard = null
660
+ if (fitRaf) {
661
+ cancelAnimationFrame(fitRaf)
662
+ fitRaf = 0
663
+ }
582
664
  terminal?.dispose()
583
665
  terminal = null
584
666
  fitAddon = null
667
+ didInitialFit = false
585
668
  })
586
669
 
587
- defineExpose({
670
+ const exposed: RsTerminalExpose = {
588
671
  write,
589
672
  clear,
590
673
  focus,
@@ -592,8 +675,13 @@ defineExpose({
592
675
  copySelection,
593
676
  pasteFromClipboard,
594
677
  selectAll,
678
+ getSelection: getSelectionText,
679
+ hasSelection: () => hasSelection.value,
680
+ getGeometry: currentGeometry,
595
681
  getTerminal: () => terminal,
596
- })
682
+ }
683
+
684
+ defineExpose(exposed)
597
685
  </script>
598
686
 
599
687
  <template>
@@ -602,35 +690,31 @@ defineExpose({
602
690
  :items="contextMenuItems"
603
691
  @select="onContextMenuSelect"
604
692
  >
605
- <div class="rs-terminal-shell">
606
- <section
607
- class="rs-terminal"
608
- :class="{ 'rs-terminal--zebra': zebraStripes }"
609
- :style="zebraStyle"
610
- @click="focus"
611
- @contextmenu.capture="onTerminalContextMenu"
612
- >
613
- <div ref="hostEl" class="rs-terminal__host" />
614
- <RsLoading v-if="showLoading" class="rs-terminal__loading" />
615
- <output v-if="overlay" class="rs-terminal__overlay">
616
- {{ overlay }}
617
- </output>
618
- </section>
619
- </div>
693
+ <section
694
+ v-bind="$attrs"
695
+ class="rs-terminal"
696
+ :class="{ 'rs-terminal--zebra': zebraStripes }"
697
+ :style="zebraStyle"
698
+ @click="focus"
699
+ @contextmenu.capture="onTerminalContextMenu"
700
+ >
701
+ <div ref="hostEl" class="rs-terminal__host" />
702
+ <RsLoading v-if="showLoading" class="rs-terminal__loading" />
703
+ <output v-if="overlay" class="rs-terminal__overlay">
704
+ {{ overlay }}
705
+ </output>
706
+ </section>
620
707
  </RsContextMenu>
621
708
  </template>
622
709
 
623
710
  <style scoped>
624
- .rs-terminal-shell {
625
- display: block;
626
- height: 100%;
627
- min-height: 0;
628
- }
629
-
630
711
  .rs-terminal {
631
712
  position: relative;
632
713
  height: 100%;
633
714
  min-height: 0;
715
+ /* 作为 flex/grid 项时,xterm 行盒的固有宽度不得撑开轨道,
716
+ 否则容器收窄后列数降不下来、行尾字符会被 overflow 裁掉。 */
717
+ min-width: 0;
634
718
  border: 1px solid var(--rs-terminal-border);
635
719
  border-radius: var(--rs-radius-md);
636
720
  background: var(--rs-terminal-shell-bg, var(--rs-terminal-bg));
@@ -679,38 +763,58 @@ defineExpose({
679
763
  line-height: var(--rs-line-height-normal);
680
764
  }
681
765
 
766
+ /*
767
+ * letter-spacing 必须在终端内归零:宿主应用常在祖先上设负字距(NiuMa 的 .nm-app 是 -0.011em)。
768
+ * 它会继承进 xterm 隐藏的字宽测量元素(.xterm-helpers 下的 WidthCache),使 offsetWidth 量到的
769
+ * 字形比实际渲染窄;而 CharSizeService 走 canvas measureText 不受 CSS 影响、单元格宽是准的。
770
+ * DOM 渲染器于是按 `cellWidth - 测量值` 给每个字符补正字距,行盒却仍是 cols × cellWidth,
771
+ * 逐列累积后整行溢出十几到几十像素,被行盒的 overflow:hidden 切掉行尾几个字符。
772
+ */
682
773
  .rs-terminal :deep(.xterm) {
683
774
  width: 100%;
684
775
  height: 100%;
776
+ line-height: normal;
777
+ letter-spacing: normal;
778
+ }
779
+
780
+ /*
781
+ * xterm 6 官方仍给空的 .xterm-viewport 写 overflow-y:scroll + 背景 #000。
782
+ * 真正滚动的是 .xterm-scrollable-element;空层滚动条会挡住最后一列。
783
+ */
784
+ .rs-terminal :deep(.xterm .xterm-viewport) {
785
+ overflow: hidden;
786
+ background-color: transparent;
685
787
  }
686
788
 
687
789
  /*
688
- * 滚动:保持 xterm 默认 overflow-y:scroll(预留槽位,Fit 更稳),
689
- * 仅隐藏横向条,避免右下角出现「一点」水平滚动条。
690
- * 不要用 width/height:!important 去撑 .xterm-screen——会和字符格几何打架。
790
+ * xterm 兜底的滑块是占满 14px 轨道的直角块,配色取终端前景色 20% 不透明度,与 RsScrollbar
791
+ * 的圆角胶囊不一致。轨道宽度由 overviewRuler.width 决定,改它会给每个终端实例多建一块
792
+ * overview ruler 画布,因此只重绘滑块:热区仍是 14px,靠透明边框内缩出 10px 可见胶囊。
691
793
  */
692
- .rs-terminal :deep(.xterm-viewport) {
693
- overflow-x: hidden;
694
- overflow-y: scroll;
695
- scrollbar-width: thin;
696
- scrollbar-color: color-mix(in srgb, var(--rs-terminal-fg) 28%, transparent) transparent;
794
+ .rs-terminal :deep(.xterm-scrollable-element > .scrollbar > .slider) {
795
+ box-sizing: border-box;
796
+ border: var(--rs-scrollbar-padding, 2px) solid transparent;
797
+ border-radius: var(--rs-radius-full);
798
+ background-color: color-mix(in srgb, var(--rs-muted) 60%, transparent);
799
+ background-clip: padding-box;
800
+ transition: background-color var(--rs-transition-fast);
801
+ }
802
+
803
+ .rs-terminal :deep(.xterm-scrollable-element > .scrollbar > .slider:hover) {
804
+ background-color: color-mix(in srgb, var(--rs-primary) 40%, var(--rs-muted));
697
805
  }
698
806
 
699
- .rs-terminal--zebra :deep(.xterm) {
700
- font-size: var(--rs-terminal-font-size);
701
- line-height: var(--rs-terminal-line-height, 1.2);
807
+ .rs-terminal :deep(.xterm-scrollable-element > .scrollbar > .slider.active) {
808
+ background-color: color-mix(in srgb, var(--rs-primary) 55%, var(--rs-muted));
702
809
  }
703
810
 
704
811
  .rs-terminal--zebra :deep(.xterm-rows),
705
- .rs-terminal--zebra :deep(.xterm-rows > div) {
812
+ .rs-terminal--zebra :deep(.xterm-rows > div),
813
+ .rs-terminal--zebra :deep(.xterm-row) {
706
814
  background-color: transparent !important;
707
815
  }
708
816
 
709
- /*
710
- * 斑马纹画在 viewport + background-attachment:local:
711
- * 覆盖字符格取整后的右/下余量,并随 scrollback 一起滚。
712
- */
713
- .rs-terminal--zebra :deep(.xterm-viewport) {
817
+ .rs-terminal--zebra :deep(.xterm-scrollable-element) {
714
818
  background-color: transparent !important;
715
819
  background-image: repeating-linear-gradient(
716
820
  to bottom,
@@ -10,11 +10,12 @@ export type RsDialogWidthPreset = 'sm' | 'md' | 'lg'
10
10
  /**
11
11
  * 对话框宽度:预设或自定义(number 按 px;string 为 CSS 长度,如 `40rem` / `60%`)。
12
12
  * window 布局会在打开时把 px / rem / % 折成像素再居中(% 相对视口扣除 inset);
13
- * confirm 布局仍把自定义宽度当作 CSS 写入。
13
+ * form / confirm 布局仍把自定义宽度当作 CSS 写入。
14
14
  */
15
15
  export type RsDialogWidth = RsDialogWidthPreset | number | string
16
16
 
17
- export type RsDialogLayout = 'window' | 'confirm'
17
+ /** window:可缩放工作窗;form:居中轻量表单/说明;confirm:历史别名,等同 form */
18
+ export type RsDialogLayout = 'window' | 'form' | 'confirm'
18
19
 
19
20
  /** 关闭来源,供 beforeClose / afterClose 区分 */
20
21
  export type RsDialogCloseReason =
@@ -672,14 +672,14 @@
672
672
  white-space: nowrap;
673
673
  min-width: 0;
674
674
  }
675
- /* 自定义 slot 内容也需受 ellipsis 容器约束,避免长文本撑破列宽 */
675
+ /* 只约束收缩。max-width:100% 会把 slot 卡在容器宽上,
676
+ 父级 scrollWidth===clientWidth,公共溢出计算失效。 */
676
677
  .rs-table__ellipsis-text > * {
677
- max-width: 100%;
678
678
  min-width: 0;
679
679
  }
680
680
  .rs-table__shared-tip {
681
681
  position: fixed;
682
- z-index: var(--rs-z-tooltip);
682
+ z-index: var(--rs-z-dropdown);
683
683
  width: max-content;
684
684
  max-width: min(16rem, 90vw);
685
685
  padding: 0.375rem 0.625rem;
@@ -153,8 +153,8 @@ export interface RsTableColumn<T extends RsTableRowData = Record<string, unknown
153
153
  fixed?: RsTableColumnFixed
154
154
  ellipsis?: boolean
155
155
  /**
156
- * 悬停提示文案。启用表格级共享 Tooltip(整表单浮层),适合大表场景。
157
- * 与 `ellipsis` 独立:未省略时也会显示。
156
+ * 格子里看不到的额外提示(always)。不要重复可见文本。
157
+ * 可见文本被截断时的提示用 `ellipsis`:只量表格自身省略容器。
158
158
  */
159
159
  tooltip?: (row: T, index: number) => string | null | undefined
160
160
  /** 表头原生 title(宽表勿逐列挂 RsTooltip,避免成百实例) */
@@ -6,6 +6,28 @@ export type RsResolvedTerminalTheme = 'light' | 'dark'
6
6
 
7
7
  export type RsTerminalAction = 'copy' | 'paste' | 'selectAll' | 'clear' | 'askAi'
8
8
 
9
+ export type RsTerminalGeometry = { cols: number; rows: number }
10
+
11
+ /** `RsTerminal` 通过 `defineExpose` 公开的命令式接口。 */
12
+ export interface RsTerminalExpose {
13
+ write: (data: string) => void
14
+ clear: () => void
15
+ focus: () => void
16
+ fit: () => Promise<void>
17
+ copySelection: () => Promise<void>
18
+ pasteFromClipboard: () => Promise<void>
19
+ selectAll: () => void
20
+ getSelection: () => string
21
+ hasSelection: () => boolean
22
+ getGeometry: () => RsTerminalGeometry | null
23
+ /**
24
+ * 逃生舱口:直接取 xterm 实例。
25
+ * 终端选项由组件按 props 托管,外部改写 `terminal.options` 会与之打架(尤其整体替换该对象
26
+ * 会逐项重设、触发全量重测)。需要什么能力优先提为 prop 或加进本接口。
27
+ */
28
+ getTerminal: () => Terminal | null
29
+ }
30
+
9
31
  const TERMINAL_CSS_KEYS: Array<[keyof ITheme, string]> = [
10
32
  ['background', '--rs-terminal-bg'],
11
33
  ['foreground', '--rs-terminal-fg'],
@@ -8,9 +8,28 @@ export function isTerminalViewportScrolledUp(terminal: Terminal): boolean {
8
8
  return buffer.viewportY < buffer.baseY
9
9
  }
10
10
 
11
+ export function isAlternateTerminalBuffer(terminal: Terminal | null | undefined): boolean {
12
+ return terminal?.buffer.active.type === 'alternate'
13
+ }
14
+
11
15
  /**
12
- * 滚轮策略:默认滚轮不翻 scrollback,转为方向键发给 PTY(避免 top/vim 类全屏 TUI 错位);
13
- * 按住 Shift 时滚轮才滚动历史。
16
+ * shift:仅全屏 TUI(备用屏,vim/top/less)把滚轮转成方向键;
17
+ * 普通 shell 必须滚 scrollback,否则 bash/zsh 会当成历史命令。
18
+ */
19
+ export function shouldConvertWheelToArrowKeys(options: {
20
+ modifier: RsTerminalWheelScrollModifier
21
+ shiftKey: boolean
22
+ alternateBuffer: boolean
23
+ }): boolean {
24
+ if (options.shiftKey || options.modifier === 'none') {
25
+ return false
26
+ }
27
+ return options.alternateBuffer
28
+ }
29
+
30
+ /**
31
+ * 滚轮策略:shell 里滚历史缓冲;仅备用屏才把滚轮发给 PTY。
32
+ * 按住 Shift 时始终原生滚动。
14
33
  */
15
34
  export function attachWheelScrollGuard(
16
35
  hostEl: HTMLElement,
@@ -18,18 +37,24 @@ export function attachWheelScrollGuard(
18
37
  modifier: () => RsTerminalWheelScrollModifier
19
38
  inputEnabled: () => boolean
20
39
  onArrowKeys: (data: string) => void
40
+ isAlternateBuffer?: () => boolean
21
41
  },
22
42
  ): () => void {
23
- const viewport = hostEl.querySelector<HTMLElement>('.xterm-viewport')
43
+ const viewport =
44
+ hostEl.querySelector<HTMLElement>('.xterm-scrollable-element') ??
45
+ hostEl.querySelector<HTMLElement>('.xterm-viewport')
24
46
  if (!viewport) {
25
47
  return () => undefined
26
48
  }
27
49
 
28
50
  const handler = (ev: WheelEvent): void => {
29
- if (options.modifier() === 'none') {
30
- return
31
- }
32
- if (ev.shiftKey) {
51
+ if (
52
+ !shouldConvertWheelToArrowKeys({
53
+ modifier: options.modifier(),
54
+ shiftKey: ev.shiftKey,
55
+ alternateBuffer: options.isAlternateBuffer?.() ?? false,
56
+ })
57
+ ) {
33
58
  return
34
59
  }
35
60
  ev.preventDefault()
@@ -53,6 +53,8 @@ export interface RsSelectEngineProps {
53
53
  labelInValue?: boolean
54
54
  tokenSeparators?: string[]
55
55
  autoClearSearchValue?: boolean
56
+ /** 打开下拉时把当前选中值写入搜索框;默认不回填 */
57
+ fillSearchWithValue?: boolean
56
58
  searchPlaceholder?: string
57
59
  emptyText?: string
58
60
  loadingText?: string
@@ -100,10 +102,16 @@ export function useRsSelect(
100
102
  () => Boolean(props.virtual) || flatCount.value > (props.virtualThreshold ?? 50),
101
103
  )
102
104
 
105
+ /**
106
+ * 可搜索时一律走本地过滤,并关掉 Reka 内置 filter。
107
+ * Reka 的 filterSearch 与搜索框 searchQuery 不同步(打开时会被清空),
108
+ * 默认依赖内置过滤时会出现:手输「NVA」已出「使用 NVA」,但 NVARCHAR 仍被整表挡住。
109
+ */
103
110
  const useManualFilter = computed(
104
111
  () =>
105
112
  Boolean(props.remote) ||
106
113
  useVirtual.value ||
114
+ isSearchable.value ||
107
115
  typeof props.filterOption === 'function' ||
108
116
  props.filterOption === false ||
109
117
  Boolean(props.optionFilterProp && props.optionFilterProp !== 'label') ||
@@ -340,8 +348,15 @@ export function useRsSelect(
340
348
  const pending = searchQuery.value
341
349
  await nextTick()
342
350
  await nextTick()
343
- // ComboboxInput 挂载时会 resetSearchTerm;受控 searchValue 需要写回
344
- if (pending) searchQuery.value = pending
351
+ // ComboboxInput 挂载时会 resetSearchTerm,把选中值写进搜索框。
352
+ // 默认清空;受控 / 未清理的 searchValue 写回;fillSearchWithValue 才回填当前选中。
353
+ if (pending) {
354
+ searchQuery.value = pending
355
+ } else if (props.fillSearchWithValue && !props.multiple && hasValue.value) {
356
+ searchQuery.value = singleDisplayLabel.value
357
+ } else {
358
+ searchQuery.value = ''
359
+ }
345
360
  return
346
361
  }
347
362
  if (props.autoClearSearchValue) searchQuery.value = ''