currentCursorPos function

Future<(int, int)> currentCursorPos(
  1. Stdout stdout,
  2. Stdin stdin, {
  3. Duration timeout = const Duration(milliseconds: 100),
  4. Stream<List<int>>? input,
})

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);
}