currentCursorPos function
Returns the current cursor position if possible.
This is Device Status Report.
By sending the CSI 6 n (ControlSequencesFunctions.DSR) command to stdout, we get the coordinates in stdin as CSI n;m R (ControlSequencesFunctions.CPR).
timeout is how long the terminal is given to answer.
input is where the answer is read from, stdin by default. A Stdin can
only be listened to once, so to ask more than once — or to keep reading the
input afterwards — pass a broadcast stream over it here.
Throws an UnsupportedError naming what stopped it wherever no report can
be had: stdout is no terminal to ask — a redirected output is asked
nothing at all, rather than sent a request into a file — the terminal
stayed silent for timeout, what came back was not a report, or stdin is
no terminal to read the answer from.
Implementation
Future<(int, int)> currentCursorPos(
Stdout stdout,
Stdin stdin, {
Duration timeout = const Duration(milliseconds: 100),
Stream<List<int>>? input,
}) async {
const errorText = 'Device Status Report not supported';
List<int> report;
if (!stdout.hasTerminal) {
// `tabs` answers this same question by writing nothing; this one owes an
// answer back and has nowhere to get one, so it refuses instead. Asking
// anyway would put a CSI 6 n into whatever the output really is — a log
// file, a pipe — and hold the terminal out of its modes for the whole
// timeout, waiting on a reply that has nowhere to come from.
throw UnsupportedError('$errorText: the output is not a terminal');
}
try {
final keepEchoMode = stdin.echoMode;
final keepLineMode = stdin.lineMode;
var echoModeOff = false;
var lineModeOff = false;
StreamSubscription<List<int>>? subscription;
try {
stdin.echoMode = false;
echoModeOff = true;
stdin.lineMode = false;
lineModeOff = true;
report = await _readReport(
input ?? stdin,
(taken) => subscription = taken,
() => stdout.write('${CSI}6$DSR'),
timeout,
);
} finally {
// The modes go back before the input is let go of, and that order is
// the whole of it: cancelling a subscription to the real stdin closes
// the descriptor the modes are set through, so a restore written
// behind the cancel throws instead of landing. The caller would be
// left at a terminal with no echo and told that the terminal cannot
// report its cursor — while it just had.
//
// Line mode first, mirroring the way they were turned off: Windows
// lets echo come back only once line mode is on. Nested, so a throw
// restoring one does not keep the other from being restored, and
// only what actually changed is put back — a stdin that refused a
// change is not asked to undo it. The cancel is nested outside both,
// so the input is let go of even where a restore throws.
try {
try {
if (lineModeOff) {
stdin.lineMode = keepLineMode;
}
} finally {
if (echoModeOff) {
stdin.echoMode = keepEchoMode;
}
}
} finally {
await subscription?.cancel();
}
}
} on Object catch (error, stacktrace) {
// The reason travels in the message. Everything that can go wrong in
// here comes back as the same refusal --- a terminal that will not
// answer, a stdin that is not a terminal at all, an input already
// listened to somewhere else --- and a refusal naming no reason reads
// as the first of those whichever of them it was.
Error.throwWithStackTrace(
UnsupportedError('$errorText: $error'),
stacktrace,
);
}
// CPR = CSI n ; m R, so the numbers lie between the CSI and the final R:
// two of them, one semicolon, and nothing else.
final fields = String.fromCharCodes(report, 2, report.length - 1).split(';');
if (fields.length != 2) {
// A reply carrying a third parameter is a reply to something else --- a
// DECXCPR names the page as well --- and reading the two on either side
// of the first semicolon would hand back a position that looks right
// and is not.
throw UnsupportedError(errorText);
}
final row = _position(fields.first);
final col = _position(fields.last);
if (row == null || col == null) {
throw UnsupportedError(errorText);
}
return (row, col);
}