本文最后更新于2024年11月12日,已超过 60 天没有更新,如果文章内容或图片资源失效,请留言反馈,我会及时处理,谢谢!

在命令行中不是所有的输入字符都会被当成字符处理,其中有一些字符会被识别为控制指令,在很多控制指令中以ESC(ASCII码表第27个字符)开头的这部分就叫做转义序列(escape codes),监听鼠标的事件就在其中。

ESC字符在各种编程语言中可能需要用不同的形式输出:

  • 八进制:\033
  • 特殊字符: \e
  • Unicode: \u001b
  • 十六进制: \x1B

ANSI 转义序列是一种带内信号标准,用于控制光标位置、颜色、字体样式以及视频文本终端和终端模拟器上的其他选项。某些字节序列(多数以 ASCII 转义字符和括号字符开头)被嵌入到文本中。终端将这些序列解释为命令,而不是逐字显示的文本。 ANSI 序列于 20 世纪 70 年代推出,以取代供应商特定的序列,并在 20 世纪 80 年代初开始在计算机设备市场上普及。虽然硬件文本终端在 21 世纪已越来越少,但 ANSI 标准的相关性依然存在,因为绝大多数终端仿真器和命令控制台至少都能解释 ANSI 标准的一部分。

——ANSI escape code - Wikipedia

转义序列根据ESC后面接的字符,还能分为很多的子序列,负责不同的功能

代码 缩写 全称 描述
ESC N SS2 Single Shift Two SS2用于在多字节字符集环境中临时切换到另一字符集。如在某些终端中,使用SS2可以临时调用第二字符集以显示特殊字符。
ESC O SS3 Single Shift Three SS3类似于SS2,但用于调用第三字符集。如在多字节字符环境中,SS3可以用于访问第三字符集的符号或字符。
ESC P DCS Device Control String DCS用于发送控制字符串到设备,以执行特定的设备功能。如一些打印机可以通过DCS接收定制的打印命令。
ESC [ CSI Control Sequence Introducer CSI引入一系列控制命令,用于光标移动、字符渲染等。如\033[31m将文本颜色设置为红色。
ESC \ ST String Terminator ST用于标记字符串的结束。
ESC ] OSC Operating System Command OSC用于与操作系统进行通信,通常用来改变终端标题或配色方案。如\033]0;New Title\可以将终端窗口标题设置为“New Title”。
ESC X SOS Start of String SOS标记一个字符串的开始,通常在特定协议中使用。
ESC ^ PM Privacy Message PM用于传输隐私信息,通常不被显示。
ESC _ APC Application Program Command APC用于向应用程序发送命令。 在一些复杂的终端应用中,APC用于触发特定的应用功能或脚本。

更多细节可以参考Fe_Escape_sequences - Wikipedia,本篇博客我们将重点关注CSI 序列

Note:

windows端cmd的echo指令默认不支持ansi转义序列(需要手动开启),可使用python的print或者sys.stdout.write,linux端bash中可以使用echo -e 指令

CSI序列

ANSI转义序列中以 ESC [ 开头的叫作 Control Sequence Introducer,简写为 CSI。以 CSI 开头的指令有很多,大致可分四类:

  • 光标移动指令

    代码 描述 作用
    CSI n A 光标上移(Cursor Up) \033[2A 光标上移2行,如果在边界就无效。
    CSI n B 光标下移(Cursor Down) \033[2B 光标下移2行,如果在边界就无效。
    CSI n C 光标前(右)移(Cursor Forward) \033[2C 光标右移2列,如果在边界就无效。
    CSI n D 光标后(左)移(Cursor Back) \033[2D 光标左移2列,如果在边界就无效。
    CSI n E 光标移下移,置于行首(Cursor Next Line) \033[2E 光标下移2行,置于行起始位置。
    CSI n F 光标移上移,置于行首(Cursor Previous Line) \033[2F 光标上移2行,置于行起始位置。
    CSI n G 设置光标列坐标(Cursor Horizontal Absolute) \033[2G 光标行位置不变,列位置变为2。
    CSI n;mH 设置光标的行列坐标(Cursor Position) \033[2;3H 位置为2行,3列(左上角为0行0列)。
    CSI s 保存当前光标的位置(Save Cursor Position) 如当前光标坐标为2行3列,使用\033[s后会保存这个位置。
    CSI u 恢复光标位置(Restore Cursor Position) 恢复光标到之前保存的坐标。
  • 清屏指令

    代码 描述 作用
    CSI n J 擦除显示(Erase in Display),细节由n的取值控制。 清除屏幕的部分区域。如果是0(或缺失),则清除从光标位置到屏幕末尾的部分。如果是1,则清除从光标位置到屏幕开头的部分。如果是2,则清除整个屏幕(在D0SANSI.SYS中,光标还会向左上方移动)。如果是3,则清除整个屏幕,并删除回滚缓存区中的所有行(这个特性是xterm添加的,其他终端应用程序也支持)。
    CSI n K 擦除行(Erase in Line),细节由n的取值控制。 清除行内的部分区域。如果是0(或缺失),清除从光标位置到该行末尾的部分。如果是1,清除从光标位置到该行开头的部分。如果是2,清除整行。光标位置不变。
  • 字符渲染指令(SGR - Select Graphic Rendition)

    用来渲染字符,如设置字符的颜色或背景色,添加下滑线,加粗等,格式为CSI n m其中n为[1, 107]的数字,随着支持的颜色的增多后被拓展成 多个用;隔开的数组。目前很多终端都能渲染24bit颜色(使用指令\033[38;2;⟨r⟩;⟨g⟩;⟨b⟩m 其中r、g、b每个8位范围0-255和图片的像素一样)。

    shell_color_gallery

    在命令行可以打印环境变量COLORTERM或TERM判断支持的颜色。如果包含24bit或者truecolor即支持24bit颜色,如果包含256则支持8bit颜色,否则为4bit。

    关于更多渲染颜色的细节可以参考ANSI escape code - Wikipedia

  • 终端控制指令

    关于鼠标事件的指令格式为CSI ? n h,其中n为可选数字:

    • 1000 模式:报告鼠标点击和按钮释放事件。
    • 1002 模式:报告鼠标所有按键的点击、释放和拖动事件(即按下按钮移动)。
    • 1003 模式:报告所有鼠标移动事件(无论是否按下按钮)和所有按键的点击事件。
    • 1004 模式:报告焦点事件(当终端获得或失去焦点)。
    • 1005 模式:启用 UTF-8 编码来报告鼠标位置,适用于大于 223 的坐标。
    • 1006 模式:使用 SGR 编码格式(\033[<b>;<x>;<y>M\033[<b>;<x>;<y>m)报告鼠标事件,支持更高的坐标范围。
      • 其中b为按键事件,左键按下时值为0(一帧,可以用来判断点击事件)然后变为32,右键按下时值为2然后变为34,滚轮键按下时值为1然后变为33,滚轮向下滚动值为64,向上滚动值为65,按键都未按下时值为35
      • x为列坐标
      • y为行坐标
    • 1015 模式:使用 URXVT 编码格式(\033[<b>;<x>;<y>R)报告鼠标事件。

    要监听鼠标事件可以使用这条序列\033[?1003h\033[?1015h\033[?1006h,当我们在bash终端输入时会发现鼠标的每个动作都会在终端输出一个SGR编码格式(1006模式)的序列(windows端需要开启虚拟终端才能使用),如果需要关闭,就输入一串相同的序列把其中的h换成l。

    ansi1006response

    接下来只要关注如何在程序中捕获这种包含鼠标信息的响应序列。

    Note: 传统上,Windows控制台不支持ANSI转义序列,而是使用自己的API进行格式化和控制。然而,许多跨平台应用程序(如那些在Unix系统上运行的程序)依赖于ANSI序列来实现控制台输出的格式化。为了提高跨平台兼容性,Microsoft在Windows 10中引入了对虚拟终端的支持。windows端开启虚拟终端的代码为:

    #include "windows.h"
    HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE);
    DWORD dwMode = 0;
    GetConsoleMode(hInput, &dwMode);
    dwMode |= ENABLE_VIRTUAL_TERMINAL_INPUT; // 增加控制台模式的选项:启用虚拟终端
    SetConsoleMode(hInput, dwMode);

获取响应序列

首先我们需要解决两个问题:

  • 行缓冲

    默认开启,该模式下命令行需要等待用户敲下回车后才把输入发送给程序,对于实时捕获鼠标移动的场景不合适。

  • 回显

    默认开启,该模式下用户输入的字符都会显示在命令行上,鼠标的响应序列也会显示,如上面的动图所示,应该禁止。

在Unix系统(linux和mac)中可以使用termios。

#include "termios.h"
termios raw = orig_termios;
raw.c_lflag &= ~(ICANON | ECHO); // 禁用标准模式(行缓冲)和回显
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);

在windows端使用windows API。

#include "windows.h"
HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE);
DWORD dwMode = 0;
GetConsoleMode(hInput, &dwMode);
dwMode &= ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT); // 禁用标准模式(行缓冲)和回显
SetConsoleMode(hInput, dwMode);

接下来可以直接使用getchar获取到响应序列

  std::string csi;
  while (true) {
      char ch = getchar();
      csi += (ch == '\033') ? 'E' : ch;
      // 序列以m或M结尾
      if (ch == 'm' || ch == 'M') {
          std::cout << "CSI: " << csi << std::endl;
          // 解析序列获取 x,y,按键 等信息
          // auto info = get_mouse_info(csi);
          csi = "";
      }

      if (ch == 'q') {
          break;
      }
  }

然后就可以解析响应序列。

  std::tuple get_mouse_info(std::string csi) {
      std::regex re("\\<(\\d+);(\\d+);(\\d+)([m|M])");
      std::smatch match;
      if (std::regex_search(csi, match, re)) {
          int b = std::stoi(match[1]);
          int x = std::stoi(match[2]);
          int y = std::stoi(match[3]);
          return std::make_tuple(x, y, b);
      } else {
          return std::make_tuple(-1, -1, -1);
      }
  }
  //b的取值
  #define MOUSE_EVENT_BUTTON_LEFT_PRESS   (32)
  #define MOUSE_EVENT_BUTTON_LEFT_CLICK   (0)
  #define MOUSE_EVENT_BUTTON_RIGHT_PRESS  (34)
  #define MOUSE_EVENT_BUTTON_RIGHT_CLICK  (2)
  #define MOUSE_EVENT_BUTTON_MIDDLE_PRESS (33)
  #define MOUSE_EVENT_BUTTON_MIDDLE_CLICK (1)
  #define MOUSE_EVENT_BUTTON_SCROLL_UP    (65)
  #define MOUSE_EVENT_BUTTON_SCROLL_DOWN  (64)

获取到鼠标坐标后,可以做的操作就非常多,比如我们可以做一个画板,当鼠标左键按下后就把光标移动到鼠标位置然后输出字符。

show_mouse_draw

代码地址 HeduAiDev-tui-tutorial

参考

ANSI escape code - Wikipedia

ANSI转义序列详解_ansi转义列表 返回-CSDN博客

ArthurSonzogni/FTXUI: :computer: C++ Functional Terminal User Interface. :heart:

linux - how to get MouseMove and MouseClick in bash? - Stack Overflow


有帮助的话请打个赏吧!