Skip to content

Commit b6d78bb

Browse files
javier-godoypaodb
authored andcommitted
feat: add key selection using shift+arrow keys
Close #15
1 parent 5eaf3cc commit b6d78bb

File tree

6 files changed

+347
-2
lines changed

6 files changed

+347
-2
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*-
2+
* #%L
3+
* XTerm Console Addon
4+
* %%
5+
* Copyright (C) 2020 - 2021 Flowing Code
6+
* %%
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* #L%
19+
*/
20+
package com.flowingcode.vaadin.addons.xterm;
21+
22+
import com.vaadin.flow.component.HasElement;
23+
24+
/**
25+
* Add selection support to XTerm using arrow keys.
26+
*/
27+
public interface ITerminalSelection extends HasElement {
28+
29+
/** Sets the command line prompt. */
30+
default void setKeyboardSelectionEnabled(boolean enabled) {
31+
getElement().setProperty("keyboardSelectionEnabled", enabled);
32+
}
33+
34+
/** Returns the command line prompt. */
35+
default boolean getKeyboardSelectionEnabled() {
36+
// the feature is enabled by default
37+
// getProperty defaults to false in case the mixin isn't applied
38+
return getElement().getProperty("keyboardSelectionEnabled", false);
39+
}
40+
41+
}

src/main/resources/META-INF/frontend/fc-xterm/xterm-console-mixin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import { Terminal } from 'xterm'
2121
import { TerminalMixin, TerminalAddon } from '@vaadin/flow-frontend/fc-xterm/xterm-element';
2222

23-
interface IConsoleMixin extends TerminalMixin {
23+
export interface IConsoleMixin extends TerminalMixin {
2424
escapeEnabled: Boolean;
2525
insertMode: Boolean;
2626
readonly currentLine: string;
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*-
2+
* #%L
3+
* XTerm Selection Addon
4+
* %%
5+
* Copyright (C) 2020 - 2021 Flowing Code
6+
* %%
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* #L%
19+
*/
20+
import { Terminal } from 'xterm'
21+
import { TerminalMixin, TerminalAddon } from '@vaadin/flow-frontend/fc-xterm/xterm-element';
22+
import { IConsoleMixin } from '@vaadin/flow-frontend/fc-xterm/xterm-console-mixin';
23+
24+
interface ISelectionMixin extends TerminalMixin {
25+
keyboardSelectionEnabled: boolean;
26+
}
27+
28+
class SelectionAddon extends TerminalAddon<ISelectionMixin> {
29+
30+
__selectionLength: number;
31+
__selectionAnchor: number;
32+
__selectionRight: boolean = true;
33+
34+
activateCallback(terminal: Terminal): void {
35+
36+
var inputHandler = ((this.$core) as any)._inputHandler;
37+
38+
let resetSelection = () => {
39+
this.__selectionAnchor = undefined;
40+
}
41+
42+
let clearSelection = () => {
43+
if (!this.$.keyboardSelectionEnabled) return;
44+
resetSelection();
45+
terminal.clearSelection();
46+
}
47+
48+
let ensureSelection = () => {
49+
if (this.__selectionAnchor === undefined) {
50+
let buffer = inputHandler._bufferService.buffer;
51+
this.__selectionAnchor = buffer.y * terminal.cols + buffer.x;;
52+
this.__selectionLength = 0;
53+
}
54+
};
55+
56+
let moveSelection = (dx:number, dy:number=0) => {
57+
if (!this.$.keyboardSelectionEnabled) return;
58+
ensureSelection();
59+
60+
let newSelectionLength = this.__selectionLength;
61+
if (this.__selectionRight) {
62+
newSelectionLength += dx + dy * terminal.cols;
63+
} else {
64+
newSelectionLength -= dx + dy * terminal.cols;
65+
}
66+
67+
if (newSelectionLength<0) {
68+
newSelectionLength = -newSelectionLength;
69+
this.__selectionRight = !this.__selectionRight;
70+
}
71+
72+
let newSelectionStart = this.__selectionAnchor;
73+
if (!this.__selectionRight) {
74+
newSelectionStart -= newSelectionLength;
75+
}
76+
77+
if (newSelectionStart<0) return;
78+
if (newSelectionStart+newSelectionLength>terminal.buffer.active.length*terminal.cols) return;
79+
80+
let row = Math.floor(newSelectionStart / terminal.cols);
81+
let col = newSelectionStart % terminal.cols;
82+
83+
this.__selectionLength = newSelectionLength;
84+
terminal.select(col,row,newSelectionLength);
85+
};
86+
87+
let selectLeft = () => moveSelection(-1);
88+
let selectRight = () => moveSelection(+1);
89+
let selectUp = () => moveSelection(0,-1);
90+
let selectDown = () => moveSelection(0,+1);
91+
92+
let promptLength = () => (this.$ as unknown as IConsoleMixin).prompt?.length || 0;
93+
94+
let selectHome = () => {
95+
if (!this.$.keyboardSelectionEnabled) return;
96+
97+
let buffer = (terminal.buffer.active as any)._buffer;
98+
let range = buffer.getWrappedRangeForLine(buffer.ybase+buffer.y);
99+
100+
let pos = terminal.getSelectionPosition() || {startRow: buffer.ybase+buffer.y, startColumn: buffer.x};
101+
102+
resetSelection();
103+
ensureSelection();
104+
let dx = range.first * terminal.cols - this.__selectionAnchor;
105+
if (pos.startRow != range.first || pos.startColumn != promptLength()) {
106+
dx+= promptLength();
107+
}
108+
109+
moveSelection(dx);
110+
};
111+
112+
let selectEnd = () => {
113+
if (!this.$.keyboardSelectionEnabled) return;
114+
115+
let buffer = (terminal.buffer.active as any)._buffer;
116+
let range = buffer.getWrappedRangeForLine(buffer.ybase+buffer.y);
117+
118+
resetSelection();
119+
ensureSelection();
120+
moveSelection(range.last * terminal.cols + buffer.lines.get(range.last).getTrimmedLength() - this.__selectionAnchor);
121+
};
122+
123+
let deleteSelection = (ev: KeyboardEvent) => {
124+
if (!this.$.keyboardSelectionEnabled) return;
125+
if (this.__selectionAnchor!==undefined) {
126+
let buffer = (terminal.buffer.active as any)._buffer;
127+
let range = buffer.getWrappedRangeForLine(buffer.ybase+buffer.y);
128+
let pos = terminal.getSelectionPosition();
129+
if (pos && pos.startRow>=range.first && pos.endRow<=range.last) {
130+
//let selectionStart = pos.startRow * terminal.cols + pos.startColumn;
131+
if (!this.__selectionRight) {
132+
//cursor backward wrapped
133+
terminal.write("\x1b[<" + this.__selectionLength + "L");
134+
}
135+
//delete characters wrapped
136+
terminal.write("\x1b[<" + this.__selectionLength + "D");
137+
ev.stopImmediatePropagation();
138+
}
139+
}
140+
clearSelection();
141+
};
142+
143+
let hasModifiers = (ev:KeyboardEvent) => ev.shiftKey || ev.altKey || ev.metaKey || ev.ctrlKey;
144+
145+
this._disposables = [
146+
(this.$core as any).coreService.onUserInput(() => clearSelection),
147+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowLeft' && ev.shiftKey, selectLeft),
148+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowRight' && ev.shiftKey, selectRight),
149+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowUp' && ev.shiftKey, selectUp),
150+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowDown' && ev.shiftKey, selectDown),
151+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='Home' && ev.shiftKey, selectHome),
152+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='End' && ev.shiftKey, selectEnd),
153+
154+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowLeft' && !hasModifiers(ev), clearSelection),
155+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowRight' && !hasModifiers(ev), clearSelection),
156+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowUp' && !hasModifiers(ev), clearSelection),
157+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='ArrowDown' && !hasModifiers(ev), clearSelection),
158+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='Home' && !hasModifiers(ev), clearSelection),
159+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='End' && !hasModifiers(ev), clearSelection),
160+
161+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='Delete' && !hasModifiers(ev), deleteSelection),
162+
this.$node.customKeyEventHandlers.register(ev=> ev.key=='Backspace' && !hasModifiers(ev), deleteSelection),
163+
];
164+
165+
}
166+
167+
}
168+
169+
type Constructor<T = {}> = new (...args: any[]) => T;
170+
export function XTermSelectionMixin<TBase extends Constructor<TerminalMixin>>(Base: TBase) {
171+
return class XTermSelectionMixin extends Base implements ISelectionMixin {
172+
173+
keyboardSelectionEnabled: boolean = true;
174+
175+
connectedCallback() {
176+
super.connectedCallback();
177+
let addon = new SelectionAddon();
178+
addon.$=this;
179+
this.node.terminal.loadAddon(addon);
180+
}
181+
182+
}
183+
}

src/main/resources/META-INF/frontend/fc-xterm/xterm.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ import { XTermClipboardMixin } from '@vaadin/flow-frontend/fc-xterm/xterm-clipbo
2424
import { XTermConsoleMixin } from '@vaadin/flow-frontend/fc-xterm/xterm-console-mixin';
2525
import { XTermFitMixin } from '@vaadin/flow-frontend/fc-xterm/xterm-fit-mixin';
2626
import { XTermInsertFixMixin } from '@vaadin/flow-frontend/fc-xterm/xterm-insertfix-mixin';
27+
import { XTermSelectionMixin } from '@vaadin/flow-frontend/fc-xterm/xterm-selection-mixin';
2728

2829
@customElement('fc-xterm')
29-
export class XTermComponent extends XTermInsertFixMixin(XTermClipboardMixin(XTermConsoleMixin(XTermFitMixin(XTermElement)))) {
30+
export class XTermComponent extends XTermInsertFixMixin(XTermClipboardMixin(XTermConsoleMixin(XTermSelectionMixin(XTermFitMixin(XTermElement))))) {
3031

3132
}
3233

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*-
2+
* #%L
3+
* XTerm Console Addon
4+
* %%
5+
* Copyright (C) 2020 - 2021 Flowing Code
6+
* %%
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* #L%
19+
*/
20+
package com.flowingcode.vaadin.addons.xterm.integration;
21+
22+
import static com.flowingcode.vaadin.addons.xterm.integration.XTermTestUtils.makeFullLine;
23+
import static org.hamcrest.Matchers.is;
24+
import static org.hamcrest.Matchers.isEmptyString;
25+
import static org.junit.Assert.assertThat;
26+
import org.junit.Test;
27+
import org.openqa.selenium.Keys;
28+
29+
public class SelectionFeatureIT extends AbstractViewTest {
30+
31+
@Test
32+
public void testSelectionFeature() throws InterruptedException {
33+
XTermElement term = $(XTermElement.class).first();
34+
Position pos;
35+
36+
term.write("abcd");
37+
pos = term.cursorPosition();
38+
39+
// select left, backspace
40+
term.sendKeys(Keys.SHIFT, Keys.ARROW_LEFT);
41+
assertThat(term.getSelection(), is("d"));
42+
assertThat(term.cursorPosition(), is(pos));
43+
term.sendKeys(Keys.BACK_SPACE);
44+
assertThat(term.currentLine(), is("abc"));
45+
assertThat(term.cursorPosition(), is(pos.advance(-1, 0)));
46+
47+
// select left, delete
48+
term.sendKeys(Keys.SHIFT, Keys.ARROW_LEFT);
49+
assertThat(term.getSelection(), is("c"));
50+
assertThat(term.cursorPosition(), is(pos));
51+
term.sendKeys(Keys.DELETE);
52+
assertThat(term.currentLine(), is("ab"));
53+
assertThat(term.cursorPosition(), is(pos.advance(-1, 0)));
54+
55+
// select right, delete
56+
term.sendKeys(Keys.HOME);
57+
pos = term.cursorPosition();
58+
term.sendKeys(Keys.SHIFT, Keys.ARROW_RIGHT);
59+
assertThat(term.getSelection(), is("a"));
60+
assertThat(term.cursorPosition(), is(pos));
61+
term.sendKeys(Keys.DELETE);
62+
assertThat(term.currentLine(), is("b"));
63+
assertThat(term.cursorPosition(), is(pos));
64+
65+
// select right, backspace
66+
term.sendKeys(Keys.SHIFT, Keys.ARROW_RIGHT);
67+
assertThat(term.getSelection(), is("b"));
68+
assertThat(term.cursorPosition(), is(pos));
69+
term.sendKeys(Keys.BACK_SPACE);
70+
assertThat(term.currentLine(), isEmptyString());
71+
assertThat(term.cursorPosition(), is(pos));
72+
73+
term.write("abcd");
74+
pos = term.cursorPosition();
75+
76+
// select to home, delete
77+
term.sendKeys(Keys.SHIFT, Keys.HOME);
78+
assertThat(term.getSelection(), is("abcd"));
79+
assertThat(term.cursorPosition(), is(pos));
80+
term.sendKeys(Keys.DELETE);
81+
assertThat(term.currentLine(), isEmptyString());
82+
assertThat(term.cursorPosition(), is(pos.advance(-4, 0)));
83+
84+
// select to end, delete
85+
term.write("abcd");
86+
term.sendKeys(Keys.HOME);
87+
pos = term.cursorPosition();
88+
term.sendKeys(Keys.SHIFT, Keys.END);
89+
assertThat(term.getSelection(), is("abcd"));
90+
assertThat(term.cursorPosition(), is(pos));
91+
term.sendKeys(Keys.DELETE);
92+
assertThat(term.currentLine(), isEmptyString());
93+
assertThat(term.cursorPosition(), is(pos));
94+
95+
String text = makeFullLine(term, true) + makeFullLine(term, false) + makeFullLine(term, false);
96+
97+
// select to home, delete (wrapping)
98+
term.write(text);
99+
assertThat(term.currentLine(), is(text));
100+
term.sendKeys(Keys.SHIFT, Keys.HOME);
101+
assertThat(term.getSelection(), is(text));
102+
term.sendKeys(Keys.DELETE);
103+
assertThat(term.currentLine(), isEmptyString());
104+
105+
// select to home, delete (wrapping)
106+
term.write(text);
107+
assertThat(term.currentLine(), is(text));
108+
term.sendKeys(Keys.HOME);
109+
term.sendKeys(Keys.SHIFT, Keys.END);
110+
assertThat(term.getSelection(), is(text));
111+
term.sendKeys(Keys.DELETE);
112+
assertThat(term.currentLine(), isEmptyString());
113+
114+
}
115+
116+
}

src/test/java/com/flowingcode/vaadin/addons/xterm/integration/XTermElement.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ final String currentLine() {
3333
return getPropertyString("currentLine");
3434
}
3535

36+
public String getSelection() {
37+
return (String) executeScript("return this.terminal.getSelection()");
38+
}
39+
3640
public String lineAtOffset(int offset) {
3741
return ((String) executeScript(
3842
"buffer=this.terminal._core._inputHandler._bufferService.buffer;"

0 commit comments

Comments
 (0)