Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
## 1.68.0

### Added

- **Click in the IDE.** In `omnyshell ide` / `:ide`, a left click:
- **File tree:** focuses the tree and selects the clicked file or folder; a
double-click opens the file, or expands/collapses the folder, like Enter.
- **Tabs:** switches to the clicked tab.
- **Editor:** focuses the editor and places the caret where you clicked (a
click past the end of a line lands at its end, one below the last line at
the end of the file; lines with emoji are handled correctly).
- **Terminal and AI panels:** focuses the panel.
- Like the wheel, a click is ignored while a dialog is open and never
clears a message or cancels a pending `Ctrl-Q`/`Ctrl-W`. Right, middle
and back/forward buttons, drags and releases are ignored.
- **`IdeApp` takes an optional `clock`** (`Duration Function()`) for timing
double-clicks, so embedders' tests can drive it; it defaults to a
monotonic stopwatch.

## 1.67.0

### Added
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,9 @@ agent are shared with the CLI.
terminal IDE: a file-tree sidebar, tabs of open files, per-extension syntax
highlighting (Dart, YAML, JSON, Markdown), a git-change gutter, and a built-in
editor with `Ctrl-S` save. The mouse wheel / trackpad scrolls the pane under
the pointer (while the IDE is open, hold Shift — Option/Fn on macOS — to
select text with a drag or to middle-click paste). Open it standalone on a directory with `omnyshell
the pointer; a click focuses a pane, selects a tree entry (double-click
opens it), switches tab or places the caret (while the IDE is open, hold Shift —
Option/Fn on macOS — to select text with a drag or to middle-click paste). Open it standalone on a directory with `omnyshell
ide [path]`, or as `:ide` inside a session. Operates on the local filesystem
(the synced OmnyDrive workspace), so it is instant; built on a self-contained,
dependency-free TUI engine. Works in both `connect` and `local` modes.
Expand Down
172 changes: 140 additions & 32 deletions lib/src/application/client/ide/ide_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import 'widgets/terminal_view.dart';
/// Which pane currently receives key input.
enum Focus { tree, editor, terminal, ai }

/// A mouse target in the IDE's layout, as resolved by `IdeApp._paneAt`.
enum _Pane { tree, tabBar, editor, terminal, agent }

/// A modal text prompt (e.g. the `Ctrl-L` "go to line" dialog). While one is
/// active it captures all key input: characters build up [input], Enter calls
/// [onSubmit] with the result, and Esc dismisses it.
Expand Down Expand Up @@ -95,6 +98,9 @@ class _Confirm {
/// directories holding changes), `n` new file, `N` new folder.
/// * Editor: arrows/Home/End/PageUp/Down move; typing edits; Tab inserts two
/// spaces.
/// * Mouse: the wheel scrolls the pane under the pointer; a click focuses a
/// pane, selects a tree entry (double-click opens it), switches tab, or
/// places the caret.
class IdeApp {
IdeApp({
required Workspace workspace,
Expand All @@ -106,7 +112,9 @@ class IdeApp {
CommandShield? shield,
CommandSyntax? commandSyntax,
this.theme = TokenTheme.dark,
}) : _workspace = workspace,
Duration Function()? clock,
}) : _clock = clock ?? _monotonicClock(),
_workspace = workspace,
_terminal = terminal,
_registry = registry ?? HighlighterRegistry(),
_tree = FileTree(
Expand All @@ -123,6 +131,17 @@ class IdeApp {
_shield = shield ?? CommandShield(),
_commandSyntax = commandSyntax ?? CommandSyntax.bash;

/// Time since an arbitrary fixed start, for timing double-clicks: the
/// constructor's `clock` (tests drive it), else [_monotonicClock].
final Duration Function() _clock;

/// A clock reading a started [Stopwatch]: monotonic, so a wall-clock jump
/// can neither fake nor break a double-click.
static Duration Function() _monotonicClock() {
final watch = Stopwatch()..start();
return () => watch.elapsed;
}

final Workspace _workspace;

/// The root's path rules (the tree's [FileTree.paths]): a Windows node's
Expand Down Expand Up @@ -193,12 +212,13 @@ class IdeApp {
ScreenBuffer? _screen;

/// The panes' screen rects as laid out by the last [_render]: the file tree,
/// the editor's text area (below the tab bar) and the bottom dock (terminal or agent
/// panel; [Rect.empty] while hidden). A wheel event carries only the
/// pointer's cell, so [_handleWheel] hit-tests it against these to scroll
/// the pane under the pointer rather than the focused one.
({Rect tree, Rect editor, Rect dock}) _paneRectsCache = (
/// the tab bar, the editor's text area below it, and the bottom dock
/// (terminal or agent panel; [Rect.empty] while hidden). A mouse event
/// carries only the pointer's cell, so [_paneAt] hit-tests it against these
/// to act on the pane under the pointer rather than the focused one.
({Rect tree, Rect tabBar, Rect editor, Rect dock}) _paneRectsCache = (
tree: Rect.empty,
tabBar: Rect.empty,
editor: Rect.empty,
dock: Rect.empty,
);
Expand Down Expand Up @@ -341,9 +361,12 @@ class IdeApp {

// Tab bar + editor.
final (tabRect, editorRect) = rightArea.splitTop(1);
// The editor's wheel target is its text area only: over the tab bar there
// is no text to scroll.
_paneRectsCache = (tree: treePane, editor: editorRect, dock: dockRect);
_paneRectsCache = (
tree: treePane,
tabBar: tabRect,
editor: editorRect,
dock: dockRect,
);
TabBar.render(screen, tabRect, tabs: _tabs, active: _activeTab);

({int x, int y})? cursor;
Expand Down Expand Up @@ -520,11 +543,16 @@ class IdeApp {
// ---- Key handling --------------------------------------------------------

Future<void> _handleKey(KeyEvent key) async {
// The wheel is not a keypress: it neither answers a modal nor clears the
// Only two clicks in a row make a double-click: any other input between
// them (a key, a wheel step, a dialog) breaks the pair, since it may have
// changed what the clicked row holds.
if (key.type != KeyType.click) _lastTreeClick = null;

// The mouse is not a keypress: it neither answers a modal nor clears the
// status message or a pending Ctrl-Q/Ctrl-W confirmation, so it is routed
// before all of that.
if (key.isWheel) {
await _handleWheel(key);
if (key.isMouse) {
await _handleMouse(key);
return;
}

Expand Down Expand Up @@ -622,31 +650,111 @@ class IdeApp {
}
}

/// Scrolls the pane under the pointer one step for a wheel [key], without
/// moving focus. The tree and the editor take it exactly as Up/Down (moving
/// the selection / caret, which drags the view along); the dock panels scroll
/// their output instead, since Up/Down there is the input's history.
/// Ignored while a modal (prompt or confirmation) is open, and outside the
/// panes (the hint and status rows).
Future<void> _handleWheel(KeyEvent key) async {
/// The pane at screen cell ([x], [y]) in the last [_render]'s layout, or
/// `null` outside every pane (the hint and status rows) and over a dock
/// that shows nothing. The one hit-test shared by the wheel and the click.
_Pane? _paneAt(int x, int y) {
final panes = _paneRectsCache;
if (panes.tree.contains(x, y)) return _Pane.tree;
if (panes.tabBar.contains(x, y)) return _Pane.tabBar;
if (panes.editor.contains(x, y)) return _Pane.editor;
if (panes.dock.contains(x, y)) {
if (_showTerminal && _terminalPanel != null) return _Pane.terminal;
if (_showAgent && _agentPanel != null) return _Pane.agent;
}
return null;
}

/// Routes a mouse [key] (wheel step or click) to the pane under the pointer.
/// Ignored while a modal (prompt or confirmation) is open.
Future<void> _handleMouse(KeyEvent key) async {
if (_confirm != null || _prompt != null) return;
final up = key.type == KeyType.wheelUp;
final x = key.x ?? -1;
final y = key.y ?? -1;
final panes = _paneRectsCache;
if (panes.dock.contains(x, y)) {
if (_showTerminal) {
_terminalPanel?.scrollLines(up ? 1 : -1);
} else if (_showAgent) {
_agentPanel?.scrollLines(up ? 1 : -1);
}
return;
final pane = _paneAt(x, y);
if (pane == null) return;
if (key.isWheel) {
await _handleWheel(pane, up: key.type == KeyType.wheelUp);
} else {
await _handleClick(pane, x, y);
}
}

/// Scrolls [pane] one step ([up] or down), without moving focus. The tree
/// and the editor take it exactly as Up/Down (moving the selection / caret,
/// which drags the view along); the dock panels scroll their output instead,
/// since Up/Down there is the input's history. The tab bar ignores it.
Future<void> _handleWheel(_Pane pane, {required bool up}) async {
final arrow = KeyEvent(up ? KeyType.up : KeyType.down);
if (panes.tree.contains(x, y)) {
await _handleTreeKey(arrow);
} else if (panes.editor.contains(x, y)) {
_handleEditorKey(arrow);
switch (pane) {
case _Pane.tree:
await _handleTreeKey(arrow);
case _Pane.editor:
_handleEditorKey(arrow);
case _Pane.terminal:
_terminalPanel!.scrollLines(up ? 1 : -1);
case _Pane.agent:
_agentPanel!.scrollLines(up ? 1 : -1);
case _Pane.tabBar:
break;
}
}

/// The longest gap between two clicks on the same tree row that still counts
/// as a double-click.
static const _doubleClickWindow = Duration(milliseconds: 400);

/// The tree row of the last click and when it happened (on [_clock]), to
/// recognise a double-click; cleared once used, by a click elsewhere, and by
/// any other input (see [_handleKey]).
({int index, Duration at})? _lastTreeClick;

/// Handles a left click at ([x], [y]) on [pane]:
/// * **tree:** focuses it and selects the clicked row; a second click on
/// the same row within [_doubleClickWindow] acts like Enter (opens a file,
/// expands/collapses a directory);
/// * **tab bar:** switches to the clicked tab;
/// * **editor text:** focuses the editor and places the caret there;
/// * **terminal / agent panel:** focuses it.
Future<void> _handleClick(_Pane pane, int x, int y) async {
final lastTreeClick = _lastTreeClick;
_lastTreeClick = null;
switch (pane) {
case _Pane.tree:
_focus = Focus.tree;
final nodes = _tree.visibleNodes();
final index = _treeScroll + (y - _paneRectsCache.tree.top);
if (index >= nodes.length) return; // the empty space below the entries
_treeSelected = index;
final now = _clock();
final isDouble =
lastTreeClick != null &&
lastTreeClick.index == index &&
now - lastTreeClick.at <= _doubleClickWindow;
if (isDouble) {
await _handleTreeKey(const KeyEvent(KeyType.enter));
} else {
_lastTreeClick = (index: index, at: now);
}
case _Pane.tabBar:
final i = TabBar.tabAt(
_paneRectsCache.tabBar,
tabs: _tabs,
active: _activeTab,
x: x,
);
if (i != null) await _switchTab(i - _activeTab);
case _Pane.editor:
final tab = _activeTab >= 0 ? _tabs[_activeTab] : null;
if (tab == null) return; // the welcome screen: nothing to focus
_focus = Focus.editor;
if (tab.document.isBinary) return;
final pos = EditorView.positionAt(_paneRectsCache.editor, tab, x, y);
tab.document.moveTo(pos.row, pos.col);
case _Pane.terminal:
_focus = Focus.terminal;
case _Pane.agent:
_focus = Focus.ai;
}
}

Expand Down
14 changes: 14 additions & 0 deletions lib/src/application/client/ide/tui/key.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ enum KeyType {
/// [wheelUp].
wheelDown,

/// A left-button press at [KeyEvent.x]/[KeyEvent.y] (the release, other
/// buttons and drags are not reported). Like [wheelUp], only while the driver
/// was entered with `mouseWheel: true`.
click,

/// An unrecognised sequence (ignored by widgets).
unknown,
}
Expand Down Expand Up @@ -62,9 +67,17 @@ class KeyEvent {
factory KeyEvent.wheel({required bool up, required int x, required int y}) =>
KeyEvent(up ? KeyType.wheelUp : KeyType.wheelDown, x: x, y: y);

/// A left click with the pointer at the 0-based cell ([x], [y]).
factory KeyEvent.click({required int x, required int y}) =>
KeyEvent(KeyType.click, x: x, y: y);

/// Whether this is a mouse-wheel step ([KeyType.wheelUp]/[KeyType.wheelDown]).
bool get isWheel => type == KeyType.wheelUp || type == KeyType.wheelDown;

/// Whether this came from the mouse (a wheel step or a click) rather than
/// the keyboard.
bool get isMouse => isWheel || type == KeyType.click;

/// A printable-character event for [rune].
factory KeyEvent.char(int rune) => KeyEvent(KeyType.char, rune: rune);

Expand Down Expand Up @@ -100,6 +113,7 @@ class KeyEvent {
return 'KeyEvent.ctrl($ctrl)';
case KeyType.wheelUp:
case KeyType.wheelDown:
case KeyType.click:
return 'KeyEvent.$type($x, $y)';
default:
return 'KeyEvent.$type';
Expand Down
39 changes: 24 additions & 15 deletions lib/src/application/client/ide/tui/key_decoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,31 +263,40 @@ class KeyDecoder {
events.add(const KeyEvent(KeyType.unknown));
}

/// Emits a [KeyEvent.wheel] for a vertical wheel press; any other mouse
/// report emits nothing.
/// Emits a [KeyEvent.wheel] for a vertical wheel press and a
/// [KeyEvent.click] for a left-button press; any other mouse report emits
/// nothing.
///
/// [button] is the report's button code: bit 64 marks a wheel, whose low two
/// bits pick the direction (0 up, 1 down, 2/3 horizontal); the modifier bits
/// (4 Shift, 8 Meta, 16 Ctrl) are ignored so a modified scroll still
/// scrolls. Clicks, drags and releases are reported because mode 1000 cannot
/// report the wheel alone, but the IDE has no use for them, so they are
/// dropped here rather than surfaced as [KeyType.unknown]: apps treat any key
/// as a keypress (clearing the status message, disarming a pending Ctrl-Q
/// confirmation), which a stray click must not do. [x]/[y] are the
/// pointer's 0-based cell (clamped to 0 against a malformed report).
/// bits pick the direction (0 up, 1 down, 2/3 horizontal); without it the
/// low bits are the button (0 left, 1 middle, 2 right, 3 = release in the
/// legacy encoding), bit 32 marks a drag and bit 128 an extra button
/// (back/forward), which is dropped. The modifier bits (4 Shift,
/// 8 Meta, 16 Ctrl) are ignored so a modified scroll still scrolls. Releases,
/// drags and the other buttons are dropped rather than surfaced as
/// [KeyType.unknown]: apps treat any key as a keypress (clearing the status
/// message, disarming a pending Ctrl-Q confirmation), which they must not
/// do. [x]/[y] are the pointer's 0-based cell (clamped to 0 against a
/// malformed report).
void _emitMouse(
List<KeyEvent> events, {
required int button,
required int x,
required int y,
required bool press,
}) {
// Bit 128 marks the extra buttons (8-11: back/forward and beyond), whose
// low bits would otherwise read as a left press or a wheel step.
if (!press || button & 128 != 0) return;
final isWheel = button & 64 != 0;
final direction = button & 3;
if (press && isWheel && direction < 2) {
events.add(
KeyEvent.wheel(up: direction == 0, x: x < 0 ? 0 : x, y: y < 0 ? 0 : y),
);
final isDrag = button & 32 != 0;
final low = button & 3;
final cx = x < 0 ? 0 : x;
final cy = y < 0 ? 0 : y;
if (isWheel) {
if (low < 2) events.add(KeyEvent.wheel(up: low == 0, x: cx, y: cy));
} else if (!isDrag && low == 0) {
events.add(KeyEvent.click(x: cx, y: cy));
}
}

Expand Down
6 changes: 4 additions & 2 deletions lib/src/application/client/ide/tui/terminal_driver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ abstract interface class TerminalDriver {

/// Enters full-screen mode (alternate screen + raw input).
///
/// With [mouseWheel], also asks the terminal to report the mouse wheel, which
/// the [KeyDecoder] turns into [KeyType.wheelUp]/[KeyType.wheelDown]. It is
/// With [mouseWheel], also asks the terminal to report the mouse wheel and
/// left clicks, which the [KeyDecoder] turns into
/// [KeyType.wheelUp]/[KeyType.wheelDown] and [KeyType.click] (the one mode
/// reports both, so the flag keeps its original, wheel-only name). It is
/// opt-in because while it is on most terminals stop selecting text on a
/// plain drag and pasting on a middle-click (with Shift held — Option/Fn on
/// macOS — both still work).
Expand Down
Loading
Loading