mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-06 08:02:04 +00:00
feat(mobile): close a pane; scroll full-screen programs; fix pinyin in the pane (#1065)
* fix(mobile): a swipe scrolls a full-screen program
A swipe over the terminal scrolled xterm's scrollback, and the alternate
screen keeps none, so over Claude Code's full-screen view, less or vim
it did nothing. On the alternate screen a swipe now turns the mouse
wheel instead, a notch a row, as on the desktop: a wheel report at the
finger for a program that asked for the mouse (SGR or the legacy
encoding, whichever it chose), arrow keys for one that did not.
* fix(mobile): pinyin keeps going when typing straight into the pane
Typing on the terminal itself goes through a hidden field, which was
emptied after every committed piece of text. iOS keeps its own copy of
the field's text, and a field cleared under it left the input method
stuck after the first character a pinyin composition committed. The
field now keeps its text while it has the keys and the pane is sent
what changed since the last send: characters taken off as DEL, then
what is new. It is emptied on blur.
While the field has the keys the pane's cursor is drawn solid, so a tap
on the terminal shows that it is ready to type into.
* feat(mobile): close a pane from the phone
The app could watch and drive a pane but never close one. Hold a pane's
row in the list, or pick Close pane from the terminal's menu: a sheet
says what will be stopped and on which machine, and only its danger
button closes it. From the terminal the app goes back to the list, which
drops the pane when the next tree arrives.
On the wire this is a new one-shot Open::ClosePane { pane_id, machine },
answered Ok or Denied. The gateway does what `tty7 pane close` does:
finds the workspace holding the pane, sends PaneClose, and hangs up the
panes it reports removed, on the gateway's machine or through the
desktop's existing link to a remote one. A gateway that predates the
variant drops the stream, which the app reports as "too old to close
panes from the phone".
This commit is contained in:
@@ -310,6 +310,39 @@ impl Backend for Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `tty7 pane close` does: take the pane out of its workspace's tree
|
||||
/// on the machine it lives on, then hang up every pane that removal left
|
||||
/// with no tab — the pane itself, normally.
|
||||
fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> io::Result<()> {
|
||||
let tree = match self.request_on(machine, ControlRequest::MachineGet)? {
|
||||
ReplyOk::MachineTree(tree) => *tree,
|
||||
other => return Err(unexpected("MachineGet", &other)),
|
||||
};
|
||||
let workspace = tree
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|ws| ws.tabs.iter().any(|tab| tab.root.contains(pane_id)))
|
||||
.map(|ws| ws.id)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "that pane is not open any more")
|
||||
})?;
|
||||
let removed = match self.request_on(
|
||||
machine,
|
||||
ControlRequest::PaneClose {
|
||||
workspace,
|
||||
pane: pane_id,
|
||||
},
|
||||
)? {
|
||||
ReplyOk::Panes(panes) => panes,
|
||||
other => return Err(unexpected("PaneClose", &other)),
|
||||
};
|
||||
let panes = self.panes_on(machine)?;
|
||||
for pane in removed {
|
||||
panes.kill(pane)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn running_panes(&self) -> Option<std::collections::HashSet<u64>> {
|
||||
let panes = PaneClient::local().list().ok()?;
|
||||
Some(
|
||||
|
||||
@@ -69,6 +69,9 @@ pub trait Backend: Send + Sync + 'static {
|
||||
/// Closes a tab and ends its panes, keeping it to reopen where the
|
||||
/// machine can.
|
||||
fn close_tab(&self, machine: Option<&str>, workspace_id: &str, tab_id: &str) -> io::Result<()>;
|
||||
/// Closes a pane as the desktop does: out of its tab (the tab too, if it
|
||||
/// was the last pane there), and whatever runs in it ended.
|
||||
fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> io::Result<()>;
|
||||
/// The panes this machine's server has running, when it can say.
|
||||
fn running_panes(&self) -> Option<HashSet<u64>> {
|
||||
None
|
||||
@@ -233,6 +236,20 @@ async fn serve_stream(
|
||||
finish(send).await;
|
||||
Ok(())
|
||||
}
|
||||
Open::ClosePane { pane_id, machine } => {
|
||||
let closed = {
|
||||
let backend = backend.clone();
|
||||
tokio::task::spawn_blocking(move || backend.close_pane(machine.as_deref(), pane_id))
|
||||
.await
|
||||
.map_err(io::Error::other)?
|
||||
};
|
||||
match closed {
|
||||
Ok(()) => write_msg(&mut send, &ok).await?,
|
||||
Err(e) => write_msg(&mut send, &denied(&e.to_string())).await?,
|
||||
}
|
||||
finish(send).await;
|
||||
Ok(())
|
||||
}
|
||||
Open::Diff { cwd, machine } => {
|
||||
let read = if machine.is_some() {
|
||||
Err(io::Error::other(
|
||||
|
||||
@@ -28,6 +28,8 @@ struct FakeMachine {
|
||||
typed: Mutex<Option<std_mpsc::Sender<Vec<u8>>>>,
|
||||
/// Every lease request that reached the "daemon".
|
||||
leased: Arc<Mutex<Vec<LeaseRequest>>>,
|
||||
/// Every pane closed, with the machine it was closed on.
|
||||
closed: Mutex<Vec<(Option<String>, u64)>>,
|
||||
}
|
||||
|
||||
impl FakeMachine {
|
||||
@@ -48,6 +50,7 @@ impl FakeMachine {
|
||||
},
|
||||
typed: Mutex::new(None),
|
||||
leased: Arc::default(),
|
||||
closed: Mutex::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +156,20 @@ impl Backend for FakeMachine {
|
||||
})
|
||||
}
|
||||
|
||||
fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> io::Result<()> {
|
||||
if machine == Some("me@gone:22") {
|
||||
return Err(io::Error::other("the desktop's link to me@gone:22 is down"));
|
||||
}
|
||||
if pane_id != 1 {
|
||||
return Err(io::Error::other("that pane is not open any more"));
|
||||
}
|
||||
self.closed
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((machine.map(str::to_string), pane_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_tab(
|
||||
&self,
|
||||
_machine: Option<&str>,
|
||||
@@ -509,6 +526,28 @@ async fn a_paired_phone_opens_a_tab() {
|
||||
assert!(err.to_string().contains("no workspace nope"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_paired_phone_closes_a_pane() {
|
||||
let rig = Rig::new().await;
|
||||
let session = within(rig.paired()).await;
|
||||
|
||||
within(session.close_pane(None, 1)).await.unwrap();
|
||||
within(session.close_pane(Some("me@build-box:22"), 1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
*rig.machine.closed.lock().unwrap(),
|
||||
vec![(None, 1), (Some("me@build-box:22".to_string()), 1)]
|
||||
);
|
||||
|
||||
let err = within(session.close_pane(None, 9)).await.unwrap_err();
|
||||
assert!(err.to_string().contains("not open any more"), "{err}");
|
||||
let err = within(session.close_pane(Some("me@gone:22"), 1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("is down"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_paired_phone_closes_a_tab() {
|
||||
let rig = Rig::new().await;
|
||||
|
||||
@@ -249,6 +249,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Closes a pane, ending whatever runs in it. `machine` is where it runs,
|
||||
/// as on [`Session::pane`]. The tree on the control stream shows it gone.
|
||||
pub async fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> Result<()> {
|
||||
let ask = Open::ClosePane {
|
||||
pane_id,
|
||||
machine: machine.map(str::to_string),
|
||||
};
|
||||
// `Ok` is the whole answer: the pane is closed by the time it comes.
|
||||
let (mut send, _recv) = open(&self.conn, &ask).await.map_err(|e| {
|
||||
if e.to_string().contains("without answering") {
|
||||
anyhow!(
|
||||
"tty7 on this computer is too old to close panes from the phone — update it"
|
||||
)
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let _ = send.finish();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Sends a file to the machine for a pane to be handed, returning where
|
||||
/// it landed. `machine` is where that pane runs, as on [`Session::pane`].
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
//! keystrokes up, and a few [`PaneEvent`]s beside them;
|
||||
//! - a one-shot [`Open::NewTab`] stream that starts a shell in a new tab and
|
||||
//! answers with a [`TabCreated`];
|
||||
//! - a one-shot [`Open::ClosePane`] stream that closes a pane and ends what
|
||||
//! runs in it;
|
||||
//! - a one-shot [`Open::Upload`] stream that carries a file from the phone to
|
||||
//! the machine and answers with where it landed, an [`Uploaded`];
|
||||
//! - a one-shot [`Open::Diff`] stream that answers with a git working tree's
|
||||
@@ -120,6 +122,19 @@ pub enum Open {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
machine: Option<String>,
|
||||
},
|
||||
/// Close a pane, as closing it on the desktop does: it leaves its tab (the
|
||||
/// tab with it, when it was the last pane there) and whatever runs in it
|
||||
/// is ended. One-shot: `Ok` once it is closed, or `Denied` with what went
|
||||
/// wrong. The tree on the control stream shows it gone on its own.
|
||||
///
|
||||
/// A gateway older than this variant cannot parse it and drops the stream
|
||||
/// unanswered.
|
||||
ClosePane {
|
||||
pane_id: u64,
|
||||
/// As on [`Open::Pane`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
machine: Option<String>,
|
||||
},
|
||||
/// Put a file from the phone on the machine, for a pane to be handed its
|
||||
/// path. After `Ok` the phone sends the file as bytes frames and finishes
|
||||
/// its side; the gateway answers [`Uploaded`] once the file is written.
|
||||
@@ -668,6 +683,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_names_only_the_pane_for_this_machine() {
|
||||
let open = Open::ClosePane {
|
||||
pane_id: 7,
|
||||
machine: None,
|
||||
};
|
||||
let wire = serde_json::to_value(&open).unwrap();
|
||||
assert_eq!(
|
||||
wire,
|
||||
serde_json::json!({"type": "close_pane", "pane_id": 7})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Open>(wire).unwrap(), open);
|
||||
|
||||
let remote = Open::ClosePane {
|
||||
pane_id: 7,
|
||||
machine: Some("me@box:22".into()),
|
||||
};
|
||||
let wire = serde_json::to_value(&remote).unwrap();
|
||||
assert_eq!(
|
||||
wire,
|
||||
serde_json::json!({"type": "close_pane", "pane_id": 7, "machine": "me@box:22"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Open>(wire).unwrap(), remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upload_names_its_file_and_size() {
|
||||
let open = Open::Upload {
|
||||
|
||||
+1
-1
@@ -305,7 +305,7 @@ Borderless, accent-coloured, 44×44. The back link is a 26px chevron plus the pr
|
||||
- **Internal Padding:** comes from the rows (10px/12px/14px).
|
||||
|
||||
### Rows
|
||||
The unit of every list. Avatar or tile, a title (17px/500) over a subtitle (14px, ink-2), and an ink-3 chevron. Title and subtitle each truncate to one line. A pane's subtitle leads with its coloured status word, then " · "-joined detail. Press fills cell-press instantly and fades back over 0.35s. A hibernated tab dims its avatar to 45% and carries the "Asleep" tag.
|
||||
The unit of every list. Avatar or tile, a title (17px/500) over a subtitle (14px, ink-2), and an ink-3 chevron. Title and subtitle each truncate to one line. A pane's subtitle leads with its coloured status word, then " · "-joined detail. Press fills cell-press instantly and fades back over 0.35s. A hibernated tab dims its avatar to 45% and carries the "Asleep" tag. Holding a pane row (half a second, still) asks to close that pane in a sheet with a danger button; the lift that ends the hold does not open the pane.
|
||||
|
||||
### Avatars and the status badge (signature)
|
||||
A pane is a 38px circle (30px in the terminal header): neutral (ink at 7% over cell) with a terminal glyph for a plain shell, or the agent's brand field with its mark in white (or the agent's own ink). The status badge is a 14px dot at the bottom-right, ringed 2.5px in the cell colour so it cuts into the avatar, exactly as on a desktop tab. Working blinks (1.4s, stepped); Waiting has the white hole; Done is solid; Idle has no badge.
|
||||
|
||||
@@ -50,6 +50,8 @@ connects peer-to-peer over iroh by public key, end-to-end encrypted.
|
||||
| / ~ -) and a compose box that sends a whole message, then Enter. A copy view shows the pane's text
|
||||
for native selection.
|
||||
- Forget a machine (requires re-pairing).
|
||||
- Close a pane on the machine — hold its row, or Close pane in its ⋯ menu — after a
|
||||
confirmation, since it ends whatever runs there; the desktop loses it too.
|
||||
- No framework: vanilla TypeScript with a tiny DOM helper; xterm.js for the terminal.
|
||||
- Not yet: QR scanning, push notifications, keychain storage.
|
||||
|
||||
|
||||
@@ -309,6 +309,23 @@ async fn tab_new(
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
/// Closes a pane on the machine for good, ending whatever runs in it — not
|
||||
/// [`pane_close`], which only stops this phone watching one. The tree drops
|
||||
/// it on its own.
|
||||
#[tauri::command]
|
||||
async fn pane_kill(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
host_id: String,
|
||||
machine: Option<String>,
|
||||
pane_id: u64,
|
||||
) -> CmdResult<()> {
|
||||
let session = state.session(&host_id).await?;
|
||||
session
|
||||
.close_pane(machine.as_deref(), pane_id)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
/// Closes a tab and its panes; the machine keeps it to reopen where it can.
|
||||
#[tauri::command]
|
||||
async fn tab_close(
|
||||
@@ -588,6 +605,7 @@ pub fn run() {
|
||||
refresh,
|
||||
tab_new,
|
||||
tab_close,
|
||||
pane_kill,
|
||||
upload,
|
||||
diff,
|
||||
pane_open,
|
||||
|
||||
@@ -162,6 +162,11 @@ export const tabNew = (
|
||||
export const tabClose = (hostId: string, machine: string | null, workspaceId: string, tabId: string) =>
|
||||
invoke<void>("tab_close", { hostId, machine, workspaceId, tabId });
|
||||
|
||||
/** Closes a pane on the machine, ending what runs in it. Not `paneClose`,
|
||||
* which only stops this phone watching one. */
|
||||
export const paneKill = (hostId: string, machine: string | null, paneId: number) =>
|
||||
invoke<void>("pane_kill", { hostId, machine, paneId });
|
||||
|
||||
export const paneInput = (handle: number, data: string) =>
|
||||
invoke<void>("pane_input", { handle, data });
|
||||
|
||||
|
||||
+120
-11
@@ -1986,11 +1986,29 @@ interface TabRef {
|
||||
id: string;
|
||||
name: string;
|
||||
busy: boolean;
|
||||
/** Split into more than one pane: one of them can be closed alone. */
|
||||
split?: boolean;
|
||||
}
|
||||
|
||||
function tabRef(ws: WorkspaceView, tab: TabView, name: string): TabRef {
|
||||
const busy = tab.panes.some((p) => p.agent && (p.agent.status === "working" || p.agent.status === "waiting"));
|
||||
return { workspace: ws.id, id: tab.id, name, busy };
|
||||
return { workspace: ws.id, id: tab.id, name, busy, split: tab.panes.length > 1 };
|
||||
}
|
||||
|
||||
/** Closes one pane of a split tab on the machine, asking first: whatever runs
|
||||
* there is ended, and the desktop loses it too. False when it was not
|
||||
* closed: declined, or refused, which `failed` is told. */
|
||||
async function closePane(host: Host, place: Place, pane: PaneView, title: string, failed: (message: string) => void) {
|
||||
const what = pane.agent ? `${agentLook(pane.agent.kind).name} and anything else running in it` : "Whatever runs in it";
|
||||
if (!(await confirmSheet(`Close this pane of ${title}?`, `${what} will be stopped, and it closes on ${place?.name ?? host.name} too.`, "Close pane")))
|
||||
return false;
|
||||
try {
|
||||
await api.paneKill(host.id, place?.key ?? null, pane.id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
failed(sentence(errorText(e)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes a tab, asking first when an agent in it is at work. False when it
|
||||
@@ -2177,6 +2195,13 @@ function paneRow(host: Host, place: Place, ws: WorkspaceView, tab: TabView, pane
|
||||
},
|
||||
);
|
||||
}
|
||||
if (tab.panes.length > 1)
|
||||
actions.push({
|
||||
label: "Close this pane",
|
||||
icon: "close",
|
||||
danger: true,
|
||||
run: () => void closePane(host, place, pane, name, (message) => closeFailed(name, message)),
|
||||
});
|
||||
actions.push({
|
||||
label: "Close tab",
|
||||
icon: "close",
|
||||
@@ -2594,6 +2619,19 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
icon: "phone" as const,
|
||||
run: toggleTake,
|
||||
},
|
||||
...(tab?.split
|
||||
? [
|
||||
{
|
||||
label: "Close this pane",
|
||||
icon: "close" as const,
|
||||
danger: true,
|
||||
run: async () => {
|
||||
// Nothing is left here to watch; the list drops it on its own.
|
||||
if (await closePane(host, place, pane, tab.name, (message) => showBanner(message))) hostScreen(host);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tab
|
||||
? [
|
||||
{
|
||||
@@ -2906,6 +2944,17 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
return true;
|
||||
});
|
||||
|
||||
// Whether the program asked for SGR mouse reports (`?1006`), the encoding
|
||||
// a swipe over a full-screen program is reported in; xterm keeps it to
|
||||
// itself. A replay restores it along with the other modes.
|
||||
let sgrMouse = false;
|
||||
const mouseEncoding = (on: boolean) => (params: (number | number[])[]) => {
|
||||
if (params.includes(1006)) sgrMouse = on;
|
||||
return false;
|
||||
};
|
||||
term.parser.registerCsiHandler({ prefix: "?", final: "h" }, mouseEncoding(true));
|
||||
term.parser.registerCsiHandler({ prefix: "?", final: "l" }, mouseEncoding(false));
|
||||
|
||||
let handle: number | null = null;
|
||||
// Whether keystrokes land: set by a successful open, cleared by anything
|
||||
// that says they no longer do.
|
||||
@@ -3247,17 +3296,36 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
if (document.activeElement === typing) typing.blur();
|
||||
else typing.focus({ preventScroll: true });
|
||||
};
|
||||
typing.addEventListener("focus", () => keyboard.classList.add("on"));
|
||||
typing.addEventListener("blur", () => keyboard.classList.remove("on"));
|
||||
|
||||
// Committed text goes to the pane and the field is emptied again; while
|
||||
// an input method is composing, the field holds the candidate.
|
||||
let imeOpen = false;
|
||||
const flush = () => {
|
||||
if (imeOpen || !typing.value) return;
|
||||
const text = typing.value.replace(/\r?\n/g, "\r");
|
||||
// While the field has the keys the pane's cursor is drawn solid, as a
|
||||
// focused terminal's is, so a tap on the pane shows where typing lands.
|
||||
typing.addEventListener("focus", () => {
|
||||
keyboard.classList.add("on");
|
||||
term.options.cursorInactiveStyle = "block";
|
||||
});
|
||||
typing.addEventListener("blur", () => {
|
||||
keyboard.classList.remove("on");
|
||||
term.options.cursorInactiveStyle = "outline";
|
||||
typing.value = "";
|
||||
send(text);
|
||||
typed = "";
|
||||
});
|
||||
|
||||
// Committed text goes to the pane as the field changes; while an input
|
||||
// method is composing, the field holds the candidate and nothing is sent.
|
||||
// The field is not emptied while it has the keys: iOS keeps its own copy
|
||||
// of the text, and a field cleared under it leaves the input method
|
||||
// stuck after the first character it commits. What goes out is the
|
||||
// change since the last send: characters taken off the end as DEL, one
|
||||
// each, then what is new.
|
||||
let imeOpen = false;
|
||||
let typed = "";
|
||||
const flush = () => {
|
||||
if (imeOpen || typing.value === typed) return;
|
||||
const was = Array.from(typed);
|
||||
const now = Array.from(typing.value);
|
||||
let same = 0;
|
||||
while (same < was.length && same < now.length && was[same] === now[same]) same++;
|
||||
typed = typing.value;
|
||||
send("\x7f".repeat(was.length - same) + now.slice(same).join("").replace(/\r?\n/g, "\r"));
|
||||
};
|
||||
typing.addEventListener("compositionstart", () => (imeOpen = true));
|
||||
typing.addEventListener("compositionend", () => {
|
||||
@@ -3466,6 +3534,38 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
latest.hidden = true;
|
||||
requestAnimationFrame(showCursor);
|
||||
};
|
||||
// A full-screen program (an agent's full-screen view, less, vim) is on
|
||||
// the alternate screen, which keeps no scrollback: there is nothing here
|
||||
// to scroll, and the program scrolls itself. A swipe over it turns the
|
||||
// mouse wheel, as on the desktop, a notch a row: reported at the finger
|
||||
// to a program that asked for the mouse, arrow keys to one that did not.
|
||||
let wheelPx = 0;
|
||||
let finger = { x: 0, y: 0 };
|
||||
const wheel = (dy: number) => {
|
||||
wheelPx += dy;
|
||||
const row = rowHeight();
|
||||
const notches = Math.trunc(wheelPx / row);
|
||||
if (!notches) return;
|
||||
wheelPx -= notches * row;
|
||||
const up = notches > 0;
|
||||
let notch: string;
|
||||
if (term.modes.mouseTrackingMode === "none") {
|
||||
notch = `\x1b${term.modes.applicationCursorKeysMode ? "O" : "["}${up ? "A" : "B"}`;
|
||||
} else {
|
||||
const drawn = screenEl.querySelector<HTMLElement>(".xterm-screen")?.getBoundingClientRect();
|
||||
const clamp = (n: number, max: number) => Math.min(Math.max(1, n), max);
|
||||
// The pane's own rows: the ones under them here are history.
|
||||
const x = clamp(drawn ? Math.floor(((finger.x - drawn.left) / drawn.width) * term.cols) + 1 : 1, term.cols);
|
||||
const y = clamp(drawn ? Math.floor((finger.y - drawn.top) / row) + 1 : 1, Math.min(paneRows, term.rows));
|
||||
const button = up ? 64 : 65;
|
||||
// The legacy encoding's bytes past 127 would not survive the trip as
|
||||
// text; it is capped there.
|
||||
notch = sgrMouse
|
||||
? `\x1b[<${button};${x};${y}M`
|
||||
: `\x1b[M${String.fromCharCode(32 + button, 32 + Math.min(x, 95), 32 + Math.min(y, 95))}`;
|
||||
}
|
||||
void input(notch.repeat(Math.abs(notches)));
|
||||
};
|
||||
// Moves the view by a distance in pixels. Dragging down goes back in the
|
||||
// scrollback.
|
||||
const scrollBy = (dy: number) => {
|
||||
@@ -3479,6 +3579,11 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
screenEl.scrollTop -= dy;
|
||||
return;
|
||||
}
|
||||
if (buf.type === "alternate") {
|
||||
if (frac) setFrac(0);
|
||||
wheel(dy);
|
||||
return;
|
||||
}
|
||||
const row = rowHeight();
|
||||
// Where the view is, from the top of the scrollback, and where it goes;
|
||||
// the bottom is the prompt with nothing shifted.
|
||||
@@ -3506,6 +3611,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
if ((e.target as Element).closest?.(".scrollbar")) return (touch = null);
|
||||
const t = e.touches[0];
|
||||
touch = { x: t.clientX, y: t.clientY, axis: null, samples: [[t.clientY, e.timeStamp]] };
|
||||
finger = { x: t.clientX, y: t.clientY };
|
||||
wheelPx = 0;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
@@ -3522,6 +3629,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
}
|
||||
if (touch.axis !== "y") return;
|
||||
e.preventDefault();
|
||||
finger = { x: t.clientX, y: t.clientY };
|
||||
const last = touch.samples[touch.samples.length - 1][0];
|
||||
pending += t.clientY - last;
|
||||
touch.samples.push([t.clientY, e.timeStamp]);
|
||||
@@ -3917,6 +4025,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
|
||||
replayed = true;
|
||||
loaded();
|
||||
term.reset();
|
||||
sgrMouse = false;
|
||||
};
|
||||
live = false;
|
||||
retry.cancel();
|
||||
|
||||
Reference in New Issue
Block a user