笔记:NCurses和RubyCurses绑定研究

前置补充

2022.02.26 curses-examples 补充一些代码例子


研究路线

NCurses 的资料太少了,导致研究他反而是个困难时期。

Ruby的书里只有 《Ruby Cookbook》里面提到了一些 Curses 如何使用,但是不足以说完全教会你。

有几个研究路线

  1. Ruby Curses库角度方向
    • 通过 Ruby/curses 学习他的 samples 逆向他的思想。
    • 通过查询 Ruby Curses 来弥补知识的不足。
  2. NCurses方向
    • 通过 NCurses 的课程文档(下文参考)学习基本思想。
    • 然后通过 ffi-ncurses 以近乎一致的api书写
  3. 学会路线2灵活使用路线1
    • 因为1的优势就是1作了一个兼容层可以照顾到三种Curses变体。而FFI只能照顾一种。
  4. 升级版的路线3
    • 通过学习 NCurse的思想(视频教程1小时左右),然后把 NCurse的API 在 Ruby的Curses中搜索,找到映射关联起来。
    • 主要是搜索 API,找到对应的 function 名,然后再搜索 Curses 中 API 和 function的映射

使用NCurses过于繁琐,经过一层面向对象的封装不论是Python的Curses还是Ruby的Curses都有点不一样。

Ruby的Curses封装的很简单,主要他还提供一些别的Curses变体的兼容层。这个角度是 FFI 库不可替代的。

最后我选择的是路线 4 ,通过快速的入门学习NCurses 基本思想,然后用Ruby的Curses构建程序,在此之前,需要自己做点体力活,把NCurses的API 自己以源码学习的方式和Ruby Curses建立起关联。


研究资料

History

Curses

HowTo

Cheatsheet

Ruby Binding

NCurses Video

Terminal Mode

  • Terminal_mode
    • Cooked Mode 简单说就是字符串会被处理过输出,比如 输入 ABCD 我们会得到 ABD
    • Raw Mode 就是输入字符完全被输出不被处理 比如 输入 ABCD 我们会得到 ABCD
    • CBreak Mode( Rare Mode ) 它是介于 RawMode 和 CookedMode 之间的模式。与熟模式不同,它一次处理单个字符,而不是强制等待一整行然后一次性全部输入。 与原始模式不同,像 abort(通常是 Control-C)这样的击键仍然由终端处理,并且会中断进程。
  • what-s-the-difference-between-a-raw-and-a-cooked-device-driver
    • 这个概括比较简单。terminal驱动是一个基于字符行的系统,默认的是cooked模式也叫标准模式。他的工作特点是用户输入会放在缓冲区直到遇到 Enter、Return 才会发送到程序内部。在这个阶段可以使用退格、Ctrl+U 等一些编辑命令。简单的说就是 terminal driver会事先 cooked 输入字符串,再发送至内部。
    • raw模式就是不经过terminal driver程序的处理直接发送。具体对特殊字符比如删除这个交给内部程序处理。比如 vi、emacs 都是这种模式在工作。
  • raw()函数和cbreak()函数: 通常情况下,终端驱动程序会缓冲用户输入的字符,直到遇到换行符或回车符后,这些字符才可以被使用。但是大多数程序要求字符在输入时就可以被使用。raw()和cbreak()两个函数都可以禁止行缓冲(line buffering)。区别是:在raw()函数模式下,处理挂起(CTRLZ)、中断或退出(CTRLC)等控制字符时,将直接传送给程序去处理而不产生终端信号;而在cbreak()模式下,控制字符将被终端驱动程序解释成其它字符。就我个人而言,比较喜欢使用raw()函数,因为用它可以做一些一般用户无法进行的控制操作。

  • echo()函数和noecho()函数:这两个函数用来控制是否将从键盘输入的字符显示在终端上。调用noecho()函数禁止输入的字符出现在屏幕上。也许程序员希望用户在进行控制操作时,需要屏蔽掉控制字符(如组合键操作),或调用getch()函数读取键盘字符时,不想显示输入的字符(如在控制台输入登陆密码)。大多数的交互式应用程序在初始化时会调用noecho()函数,用于在进行控制操作时不显示输入的控制字符。这两个函数给予程序员很大的灵活性,使程序员可以在窗口中的任意地方,实现输入字符的显示和屏蔽,而不需要刷新屏幕。

  • 《ncurses初始化函数:raw(),cbreak(),echo(),noecho(),keypad(),halfdelay()》

常见问题

Ruby Curses中

/*
 * Document-method: Curses.curs_set
 * call-seq: curs_set(visibility)
 *
 * Sets Cursor Visibility.
 * 0: invisible
 * 1: visible
 * 2: very visible
 */

关键实验

1. raw 和 noraw(也就是所谓的cooked) 区别

require 'curses'
include Curses

begin
  init_screen

  ## 观察下面的代码,两个互斥模式,分别注释观察运行结果
  ## 输入Ctrl+C 观察反映
  ## raw模式中,Ctrl+C 将会作为字符串显示
  ## noraw 模式下 会接受命令直接退出

  # noraw
  raw
  echo

  setpos(0,0)
  ## 这里的关键是不让程序早早退出
  ## 这三句是为了响应一次,不让程序快速结束(一句接收一个字符就程序结束,无法观察结果)
  getch
  getch
  getch

rescue Error
  close_screen
end

2. 设置属性


require 'curses'
include Curses

begin
  init_screen
  raw
  echo

  setpos(0,0)

  # 这里表现的很像Canvas,我们在一个上下文,运行时候可能就是一个对象,修改他的属性
  # 然后工作结束关闭他的属性,然后渲染工作的改变只在我们的上下文中
  attron(A_REVERSE)
  addstr("hello world")
  attroff(A_REVERSE)
  
  addstr('Off Reverse Hello world')

  getch
  getch
  getch

rescue Error
  close_screen
end

3. 颜色设置

require 'curses'
include Curses

begin
  init_screen
  raw
  echo

  setpos(0,0)

  # 首先要初始化颜色(其实还有一部检查是否支持颜色)
  start_color
  # 这里是注册一个 颜色的pair,第一个参数是自己设置的ID。后面是文字颜色,背景颜色
  init_pair(1, COLOR_GREEN, COLOR_RED)
  # 通过设置属性,以 color_pair 形式指定 颜色pair ID 来让颜色生效 
  attrset(color_pair(1))
  addstr("hello world")
  attroff(color_pair(1))
  
  addstr('Off Reverse Hello world')

  getch
  getch
  getch

rescue Error
  close_screen
end

4. 用户输入

require 'curses'
include Curses


begin
  # 初始化
  init_screen
  noecho
  crmode

  # 获得尺寸
  width = cols
  hieght = lines

  # 根据尺寸初始化屏幕
  # 绘制box
  inputwin = Window.new(3, width-5, 0,0)
  inputwin.box(0,0)
  # 启用功能键
  inputwin.keypad = true
  # 展示box
  inputwin.refresh

  # 接收用户输入
  c = inputwin.getch

  # 功能键以  KEY_开头 以源码为准,文档表意不明
  # https://www.rubydoc.info/gems/curses/Curses/Key
  # Curses::KEY_F1
  # Curses::KEY_DOWN
  # Curses::KEY_UP
  # Curses::KEY_RIGHT
  # Curses::KEY_LEFT
  # Curses::KEY_BACKSPACE

  # 如果按下的是功能键,则
  # 1.定位光标
  # 2.设置展示文字
  # 3.refresh刷新展示
  if(c == KEY_UP) 
    inputwin.setpos(1,1)
    inputwin.addstr("You press UP KEY")
    inputwin.refresh
  end

  # 设置等待一个用户字符,实际上作用是维持界面
  inputwin.getch
  inputwin.close


rescue Error
  close_screen
end

插入讨论:函数式(过程) VS 面向对象

C语言,面向过程式的代码其实和面向对象有啥区别呢?

从NCurses的代码上来看,唯独就是

NCurses 是通过过程式的函数调用来完成工作的,而这个函数调用,总要传递一个 比如 win 的对象。 然后函数是根据传入指针,其实约定了参数,然后修改win的内部属性。

那么面向对象,就是不再用一直传递这个win对象,而变成了win对象一直调用自己的方法。

其实呢,方法自己内部有一个self可以追踪到win。

所以其实是殊涂同归。

什么函数式、抑或是面向对象,他们即使书写方式不同,本质上依然是顺着指针寻找变量、方法罢了。

只能说他们记录的地方不一样,函数式是记录在代码里,字面量函数写了什么,传递什么就是什么。面向对象,就是使用了一个数据结构记录下来了。内部作了一些继承搜索查找,帮助人减少每次重复声明指针。

其实都一样。现实中也可以混用。归根结底依然是底层的这些数据结构和函数操作罢了。面向对象多了一种自己构建的数据结构。

衍生讨论:如果没有面向对象,想要简化代码的人依然可以自己构建数据结构来实现面向对象这种类似的功能 —— 看过一本 《Ruby设计模式》前言里面作者其实提到了,他们用过数组里面存储对象指针,然后用数组偏移来模拟面向对象。

我的结论:

函数式是最基础的存在,因为地层就是执行栈和函数。

面向对象多了一层数据结构,用来存储和关联对象自己、属性变量、具体方法。罢了。除了查找,归根结底,就是执行的函数。只不过这个函数内部,注入了诸如 self 、以及环境变量等东西。而这些额外的东西,是靠语言实现的特性来保证的。

如果是C语言这种,想要实现,就要自己构建数据结构,或者自己手动写代码传递。

多了一种数据结构在中间,提供了一种新的API和视觉上等效的调用方式。但是在客观上打包了变量和方法,这就是所谓提供了一种抽象。

总之,新的数据结构,代表了一种建立在这个基础上的抽象。

5. 菜单

5.1 手动实现菜单

循环一直给我造成的困扰是——我的思维里一直是快循环(或者说是内循环)。

内循环用来一次性渲染结果、渲染菜单、计算结果等等。这个已经习以为常。

思维中被忽视的还有一种是慢循环,或者说是外循环。外循环才是让一切循环往复的“智能”所在。

菜单里面整体的循环其实是:

  1. 一个小的内循环渲染第一次菜单列表
  2. 然后我们会记录输入,根据输入修改关键的变量,把索引循环化
  3. 下一个外循环,利用新的索引(或者改变,或者没改变,不重要)-> 1. 利用新的索引 渲染列表
require 'curses'
include Curses


begin
  # 初始化
  init_screen
  noecho
  crmode
  # 隐藏光标
  curs_set(0)

  # 获得尺寸
  width = cols
  hieght = lines

  # 根据尺寸初始化屏幕
  # 绘制box
  menuwin = Window.new(5, width-5, 0,0)
  # 启用功能键
  menuwin.keypad = true
  menuwin.box(0,0)
  # 展示box
  menuwin.refresh



  # 结果win
  resultwin = Window.new(5, width-15, 5,0)
  # 启用功能键
  resultwin.keypad = true
  resultwin.box(0,0)
  resultwin.setpos(0,0)
  resultwin.addstr("Result Windows")
  # 展示box
  resultwin.refresh

  choices = [
    "Ruby",
    "Python",
    "JavaScript"
  ]

  select_index = 0
  highlight_index = 0


  while true 


    # 根据初始属性,渲染列表
    # 或者根据修改后的属性,继续渲染N次列表
    choices.each_with_index do |choice, index|
      menuwin.setpos(index+1, 1)
      if(highlight_index == index)
        menuwin.attron(A_REVERSE)
        menuwin.addstr(choice)
        menuwin.attroff(A_REVERSE)
      else
        menuwin.addstr(choice)  
      end
    end

    menuwin.refresh

    # 接收用户输入
    c = menuwin.getch

    # 功能键以  KEY_开头 以源码为准,文档表意不明
    # https://www.rubydoc.info/gems/curses/Curses/Key

    # 如果按下的是功能键,则
    # 1.定位光标
    # 2.设置展示文字
    # 3.refresh刷新展示
    if(c == KEY_UP) 
      highlight_index -= 1
      if highlight_index == -1
        highlight_index = choices.length -1
      end
    elsif c == KEY_DOWN
      highlight_index += 1
      if highlight_index == choices.length
        highlight_index = 0
      end
    end

    if c == KEY_LEFT
      select_index = highlight_index
    
      # ??? 这里有个疑问
      # 我暂时没有找到方便的办法清除选中内容上一个过长内容
      # 有一些方法
      # # 删除到光标后面,避免长内容显示后,短内容只更新局部
      # # 所有的细节都是 基于 行字符的系统考虑罢了。
      # resultwin.clrtoeol # 这个优缺点会删除到边框
      # 
      # 会删除到边框
      # 这里使用了 以window为单位,进行数据重新绘制(感觉有点废性能)
      resultwin.erase
      resultwin.box(0,0)

      # 更新内容
      resultwin.setpos(1,1)
      resultwin.addstr("You Select:" + choices[select_index])
      resultwin.refresh
    end


    # 思考和自我讨论: 命令行 VS GUI/DOM
    #    
    #
    # getch 是等待输入,也是维持界面
    # 其实这也是有道理的
    # 因为命令行是给人看的、也是给人操作的,除了数据展示
    # 剩下的就是交互,而交互在命令行里面只有等待输入罢了
    # 
    # 虽然是命令行,但是也是一种前端展示
    # 前端展示本质上是一种 数据驱动的展示, 以下展示一个代表性的循环:
    # 
    # 设置展示属性初始值
    # 展示属性值 -> 根据初始值渲染元素 -> 交互等待 -> (交互改变) 属性值
    #  |                                                      |
    #   <-------------------- (这是一个外部循环)---------------|
    # 
    # 这就是 MVVM 的背后模型,适用于前端逻辑。命令行程序是这样,浏览器WEB也是如此。
    #
    # 
    # Buffer、内存、虚拟DOM 思想一致
    # 我们的代码本质上是内存里的修改,不论是 NCurses里面的缓冲区,还是Vim里面的Buffer、还是现代React的虚拟DOM
    # 都是在内存中设置要渲染的对象,程序编码的工作是把内存中模型,修改成下一帧(下一轮循环)想要的样子。
    # 然后在合适的一个周期结束的时候,推给显示器
    # 命令行里是调用 refresh,交给 terminal-driver去作
    # MVVM的框架比如React是由setState触发,最后React内部根据收集任务去调用
    #
    # 不论形式如何,最终思想是一致的。
    # 显示内容一旦交给了 显示器的外部设备,他就是稳定的存储信号了,一直维持着
    # 可以想象成最终合成的是二进制的编码,存储在显存里,然后直接传输给显示器,显示器读取内存,然后点亮像素,图像展示。
    # 
    # 这一点想说的是外部设备是稳定的
    # 我们的程序并不是在修改屏幕里面的内容,其实我们只是在修改内存里的程序罢了。可以理解为虚拟DOM
  end



rescue Error
  menuwin.close
  resultwin.close
  close_screen
end



# 执拗的迷思
# 
# 1. 循环很快,怎么维持界面变化
# 
# 循环很快是错误
# 所谓感觉上快的循环,就是很快执行完的循环,这种循环就是内循环
# 内循环作N个组建的渲染,列表元素的渲染,计算等等,是一气呵成的。
#
# 还有外循环
# 外循环实际上在感觉上很慢。因为他内部包含多个阶段,甚至是子循环。 
# 因为有外循环存在,才有点 “智能” 的感觉,多个任务序列才能一遍又一遍。
# 
# 在命令行里外循环可不快
# 还有 getch 的存在
# 循环可以卡在一个地方,等待输入再继续
# 
# 只能说外循环,不应该理解为快慢,而是整个流程可以不断重来。
# 
# 2. 循环阻塞显示怎么办?
# 
# 这个疑问也不对其实。
# 无限循环当然会阻塞流程,但是我们不会这样用
# 本质上命令行和WEB 也没啥区别
# 按照 数据驱动 绘制 UI 的思路
# 一个界面,要预先设置初始数据,然后根据数据绘制界面,然后交互影响数据,数据变化再绘制
# 这里的绘制,可以是本身就在一个大循环里
# 也可以是一种调用单独修改界面
#
# 这个问题是一个错误的思路导致的。
# 
# 我们会把整个界面初始化以及未来的N次更新,放入到一个循环中。来维持展示。直至无法展示 break 这个循环
# 进入下一个维持界面展示的循环。
#
# 而且有些界面的绘制是一次性的,通过不同调用去修改单一元素。就像前面几个例子。
# 有些界面,比如循环菜单,他需要循环在选项中选择、高亮。他就可以是循环的。
#
# 是否是循环组件,可以根据交互流程推倒得出其实。
# 这样一切就可以落在合理的基础上推演。

6. 运动

一个画板的demo

require 'curses'
include Curses


begin
  # 初始化
  init_screen
  noecho
  crmode
  # 隐藏光标
  curs_set(0)

  maxwidth = 60
  maxheight = 20

  locx = rand(1..maxwidth-1)
  locy = rand(1..maxheight-1)

  # 结果win
  playerwin = Window.new(maxheight, maxwidth, 2,2)
  playerwin.box(0,0)
  # 启用功能键
  playerwin.keypad = true
  # 展示box
  playerwin.refresh

  while true
    c = playerwin.getch
    last_locy = locy
    last_locx = locx
    if c == KEY_UP
      locy -= 1
      if locy <= 0
        locy = 1
      end
    elsif c == KEY_DOWN
      locy += 1
      if locy >= maxheight
        locy = maxheight-1
      end
    elsif c == KEY_LEFT
      locx -= 1
      if locx <=0
        locx = 1
      end
    elsif c == KEY_RIGHT
      locx += 1
      if locx >= maxwidth
        locx = maxwidth -1 
      end
    end
    playerwin.setpos(locy,locx)
    playerwin.addstr('*')
    playerwin.setpos(last_locy,last_locx)
    playerwin.addstr('.')
    playerwin.refresh
  end


rescue Error
  playerwin.close
  close_screen
end


讨论: 程序 以及 它的抽象

任何程序最终本质上执行的是顺序执行。

封装可以让程序改名字。我们的调用接口可以语义化。

面向对象把变量、方法封装在一起,其实还加入了一个环境指针。还存在继承关系。

对象是一个构建的独特的数据结构。

独特的数据结构,也改变了书写。

围绕数据结构的,还有一个例子是 XML,我们书写的是XML,而XML会被parser解析——背后是一个循环。 循环帮助顺序执行。

而我们书写的程序变成了去写XML。也改变了程序的书写。

那些框架、上层自圆其说的逻辑,就是隐藏了封装、隐藏了数据结构、使用了类似XML的新的解析语法。 最终把这个数据结构交给parser。

循环这件事本应该我们自己作。循环其实是另一种顺序。只不过被代劳了。

最终不论如何——我们展示的程序的工作特点就是:

1.(buffer中)命令代码修改内存

  1. 把修改完后的内存,交出去。

而这其中再多抽象和复杂,最终都是落在顺序命令

据个例子

1.朴素重复代码:下面的教程中演示了,如何手动编写很土味的代码。包含大量的重复。但是非常朴素。

虽然重复但是很朴素表达了实际发生的情况。

Ncurses Tutorial 14.2 - Creating a MenuBar (part 1)

2.提供抽象 —— 简化代码:

把重复的东西,用数据结构、接口方法、对象等形式封装起来 —— 简化了书写内容。

而在内存、时序上,作的事情是和 朴素重复代码 的内容是一致的其实。

所以其实一切没变。变得只是简化的代码。

Ncurses Tutorial 14.2 - Creating a MenuBar (part 2)

Mark24

Everything can Mix.