どらめも

なんでもないことをかきます

GuakeTerminalを手探ってみる

前書き

この記事はEEIC3年のAセメスター実験「大規模ソフトウェアを手探る」にてレポート課題としてdoradorasukiKotaro7750shugo256によって書かれているものです。この実験はオープンソースソフトウェアのソースコードを手探った上で何らかの機能を追加しようという実験です。今回私達のグループでは筆者の一人が日頃から愛用しているLinuxのターミナルエミュレータであるGuakeについて手探ってみました。3人の共同執筆記事ということを念頭に置いて読んでください。

環境

Ubuntu 18.04.3 LTS

課題設定

まずはじめにGitHub上のissueを各自で読み、欲しい機能を考えました。 その結果、このissueでリクエストされている機能を追加してみようということになりました。 この機能を簡潔に説明すると、タブをピンどめしてGuake自体を終了してもタブの状態を保存できるというものです。 少し調べてみたところ、標準でカレントディレクトリの情報くらいは保存して復元できるようになっており、その機能を拡張するようにすれば良さそうだと思ったのでこの課題を実装してみることにしました。

使用した内部的な機能

GuakeにはTerminalとやりとりするための様々な関数が実装されていてそれに乗っかることで様々な挙動をさせることが可能です。ここでは今回使った関数などを中心に紹介したいと思います。

ターミナルでコマンドを実行する

# guake/terminal.py

class GuakeTerminal(Vte.Terminal):
    ...
    def execute_command(self, command):
        if command[-1] != '\n':
            command += "\n"
        self.feed_child(command)

terminal.pyGuakeTerminalクラスのインスタンスメソッドです。Guake内ではタブごとにこのクラスが作られているのですが、execute_commandメソッドを実行することでターミナルで実行可能なコマンドを実行させることが可能です。

ちなみにfeed_childは親クラスであるVte.Terminalのメソッドであり、これは引数に与えたコマンドをターミナル上であたかもユーザーが実行したかのように(表示にも反映)して実行するという動きをします。Vte.TerminalGtkのターミナルエミュレート用のクラスとして用意されているもののようです。

VteTerminalのシグナルをキャッチして関数を実行する

# guake/terminal.py

class GuakeTerminal(Vte.Terminal):
    def __init__(self, guake):
        ...
        self.connect(<シグナル名>, <動作させたい関数>)
        ...

Vte.Terminalインスタンスメソッドconnectを用いると、自前のメソッドとVteTerminalのシグナルを結びつけることができます。今回は最終的には使わずに実装できましたが、キーボード入力やディレクトリ移動などを取得したいときなどに便利だ思います。ちなみに使っていた頃は上のようにGuakeTerminalクラスのコンストラクタで呼び出していました。

追加機能その1:開いているタブのコマンド実行履歴をバックアップする

現状と目標

ターミナルに与えたコマンドを保存する機能自体は多くのシェルの機能として実装されています。例えばBashであればコマンドは実行するたびにキャッシュされ、Bashが終了するタイミングで.bash_historyというファイルに書き込まれます。 しかしこの機能では、下図のように別々のターミナルで実行したコマンドも区別されずに同一のファイルに保存されてしまうので、複数のターミナルのhistoryがごちゃ混ぜになってしまうことがよくあります。
ターミナルごとにhistoryが混ざらずに残ってくれると嬉しいなあということで、今回はGuakeの機能としてタブごとのコマンドのバックアップ機能を作ることとしました。具体的にはGuakeの各タブに対してユニークなhistoryファイルを割り当て(下図)、そこにそのタブでコマンドが叩かれるたびにその内容を書き込んでいくというもので、Guakeが終了したとしても次回起動時に復帰できるということを目標としました。

実装

上記の設計から気付くとは思いますが、今回の実装は基本的にBash依存となっています。 というのも、historyを保存するhistoryコマンドがBashのビルトイン関数であり、他のシェルを使っている場合にはそもそも保存されないからです。 一応シェル依存にならないように他の機構を考えはした(後述)のですが、どれもあまり美しくなかったのでこの機構を採用しました。

実装は大きく分けて以下の2つに大別されるのでそれぞれ説明していきます。

コマンド履歴保存

Guakeにはもともと備わっている機能として、タブが何個開いていて、どのディレクトリにいるのかという情報をファイルに保存するというものがあり、Guakeを開いた際にファイルからタブ情報を読み込むことで復元しています。このファイルを拡張してコマンド履歴の保存ファイルの場所も保存する設計にしました。

タブとテキストファイルを紐付ける

上述のタブ情報はXDG_CONFIG_HOME/session.json(環境変数で指定されています)に保存されています。ここに以下のように新たにhistoryというkeyを追加し、そのvalueをコマンド履歴保存用のテキストファイルへのパスとすることで各タブとそのテキストファイルを紐付けるようにしました。

{
    "schema_version": 2,
    "timestamp": 1572598653,
    "workspace": {
        "0": [
            [
                {
                    "panes": [
                        {
                            "type": "term",
                            "directory": "/home/denjo/.config/guake",
                            "history": "/home/denjo/.config/guake/tabs/HoNYAJD7Y5.txt"
                        }
                    ],
                    "label": "denjo@DJ00059: ~/.config/guake",
                    "custom_label_set": false
                }
            ]
        ]
    }
}

具体的には、下のようにGuakeTerminalクラスのインスタンス変数としてhistory_pathを用意し、ここにhistoryファイルへのパスを持っておくようにしました。ちなみにcreate_random_file_name()はXDG_CONFIG_HOME/guake/tabs/以下のランダムなテキストファイル名を生成する関数です。

# guake/terminal.py

class GuakeTerminal(Vte.Terminal):
    def __init__(self, guake):
        ...
        self.history_path = self.create_random_file_name()
        ...

ここで定義したファイル名history_pathは、タブごとの情報をsession.jsonに書き込める形式に変換して返す関数であるsave_box_layout()を拡張することで実際にファイルに書き込むようにしています。

# guake/boxes.py
    def save_box_layout(self, box, panes: list):
       ...
            directory = box.terminal.get_current_directory()
            panes.append({'type': btype, 'directory': directory,'history': str(box.terminal.history_path)})

boxはメンバにそれぞれのターミナルの情報を持っており、そこから上述のhistory_pathを取得して、'history'のvalueとして追記することでsession.jsonに書き込みました。

タブのコマンド履歴をテキストファイルに書き込む

どうやらnotebook.pyTerminalNotebookクラスのインスタンスメソッドnew_page_with_focusが、タブ生成時に呼ばれる関数であるということがpdbなどを用いることで判明したので、実装としてはこの関数のreturnの手前に下のような2行を書き加えるだけです。

# guake/notebook.py

class TerminalNotebook(Gtk.Notebook):
    ...
    def new_page_with_focus(...):
        ...
        command = 'PROMPT_COMMAND=$PROMPT_COMMAND"\n history -w {}"'.format(terminal.history_path)
        terminal.execute_command_and_reset_output(command)

        return box, page_num, terminal

まず、terminal.execute_command_and_reset_output(command)についてterminalGuakeTerminalクラスのインスタンスであり、execute_command_and_reset_outputメソッドについては後述しますが、前述のexecute_commandメソッドを用いてコマンドを実行した上でエスケープシーケンスを用いて出力を削除する関数です。これがないとユーザーの画面に堂々とhistory -wコマンドが表示されてしまい、流石にカッコ悪いです。

この関数に渡すcommandは、PROMPT_COMMANDという環境変数に対してhistoryを書き込むコマンドhistory -w [filename]を書き加えるという処理です。PROMPT_COMMANDBash特有の機能であり、この変数に書かれたコマンドが、プロンプトが表示される直前(=コマンドの実行終了直後)に実行されるというものです。

historyコマンドおよび環境変数PROMPT_COMMANDはともにBash依存の機能ではありますが、これらと似たような機能はzshなど他のシェルにおいても備わっている(と信じている)ので、少しの変更で対応できるのではないかと踏んでいます。

エスケープシーケンスについて

上述のexecute_command_and_reset_outputメソッドはGuakeTerminalクラスのメソッドとして以下のように実装しました。

# guake/terminal.py

class GuakeTerminal(Vte.Terminal):
    ...
    def execute_command(self, command):
        ...
    
    def execute_command_and_reset_output(self, command):
        command = ' ' + command
        command += '\n echo -en "\e[0;0H\e[0J"'
        self.execute_command(command)

ここでは実行したいコマンドの後にechoコマンドを加えるということをしています。-eエスケープシーケンスを有効にし、-nは出力後に改行を入れないというオプションです。\e[0;0Hでターミナルの先頭に移動し、\e[0Jで画面の出力のクリアを行うので、この二つの呪文によりターミナル画面のリセットが実現できます。

なお、上述のPROMPT_COMMANDのセットはターミナル起動時にしか行わないので、ターミナル使用中に画面がリセットされてしまうということはありません。またシェルの画面リセットのコマンドとしてresetclearがありますが、これらはともに不採用となりました。resetは画面以外の色々なこともリセットしている(要出典)ようで実行に数秒かかってしまい、clearはぱっと見画面がリセットされたように見えるだけで上にスクロールすると元の画面が消えずに残ってしまっていたからです。

ちなみにcommandの先頭にスペースを加えているのは、このコマンドをターミナルのhistoryに反映させないための裏技です。

コマンド履歴復帰

Guakeにもともと備わっているタブ情報復元処理が私達がやりたい処理のタイミングと内容が似ているため、タブ情報の復帰のタイミングでhistory -rコマンドを実行する設計にしました。

タブ情報の復帰はguake_app.pyGuakeクラスのインスタンスメソッドrestore_tabssession.jsonから読みだすという形で行われているようです。大きなメソッドなので実装は割愛しますが、この関数の中でsession.jsonhistoryに書かれたfilepathを読み、history -r <filepath>を各タブで実行することでコマンド履歴の復元を実現している。

ボツ案

上で書いた機構以外にもいくつか考えた機構があるので紹介していこうと思います。

入力を検知してコマンドを抽出し保存する

"使用した内部的な機能"の節で説明したように、Vte.Terminalクラスを用いればターミナルの出す様々なシグナルを利用できます。その一つにテキストが入力されたときに発火されるというもの(“commit”)があります。

このシグナルを受け取り、入力された文字を取得することでコマンドを取得できると考えました。 しかし、このイベントの発火条件がテキストの状態が変わったときとなっているため、Delete周りの処理が煩雑になると考え、この機構はボツとなりました。

Enterキーを押されたらhistoryコマンドを実行する

前述のVteTerminalのシグナルの中に画面に描画されている文字が変更されたときに発火されるもの(“text-inserted”)があります。

このシグナルを使うと、VteTerminalに対して送ろうとしている文字列を取得することが可能であるため、Enterキーが押されたときにhistoryコマンドを実行した上でそのhistoryコマンドの描画をエスケープシーケンスを用いて消そうと考えました。しかしながら、エスケープシーケンスがうまく動作させることができずこの機構はボツとなりました。

画面に表示されている文字列からコマンドを抽出する

Vte.Terminalクラスではget_textというメソッドが提供されていて、これを用いるとプロンプトを含めたターミナルに表示されているテキスト全体をstringとして取得することができます。

この関数を上述の手法と同様にして、Enterキーが押されるたびに呼び出して最後の行だけを取り出せば、[プロンプト]+[直近の実行コマンド]という文字列が得られます。したがってプロンプトの文字列を保持しておけば、実行コマンドのみを取り出せるので、これをhistoryファイルに一行ずつ書き込めばコマンド実行ごとにhistoryが更新されることになります。

シグナルを用いればcdでプロンプトの長さが変わるタイミングも検知できたので、このアプローチは一応成功しました。しかし、プロンプトの長さを保持するやり方は無理矢理感満載で美しくないこと、そして前述のPROMPT_COMMANDといううってつけの機能の存在に気づいたことによりボツとなりました。

シェルの標準入力からコマンドを取ってくる

標準入力からコマンドを取得すること自体は可能でしたが、コマンドを投げつけること(改行コードの送信)がなぜかできませんでした。

追加機能その2:複数のタブで同時にコマンドを実行できるようにする

現状と目標

少し講義の時間が余ったので教授に複数タブで同じコマンドを同時実行できたらsshとかするときに便利だよねっていうことで同時実行するコマンドを作ることにしました。 現状同じことをやろうとするとコピペする、スクリプトを組んで実行するなどの方法が考えられますが、もっと手軽に実行できるようにするために専用のUIを作ってそこから実行させるように設計しました。

実装

挙動としては、 1. Ctrl+Shift+sでコマンド実行のためのウィンドウを開き、 2. 実行するコマンドを入力し、 3. 実行するタブを選択し、 4. Enter又はクリックで実行される

としました。

ウィンドウクラスの作成

ウィンドウはGtkを用いて下のように作成しました。

チェックボックスを外したものに関してはコマンドが実行されません。

実装方法としてはGtkのサンプルコードをもとにclassの生成を行いました。

class MultiExecWindow(Gtk.Window):

    def __init__(self,guake):
        super(MultiExecWindow, self).__init__()

        self.guake=guake
        self.init_ui()
        self.text=""

    def init_ui(self):    

        grid = Gtk.Grid()
        execBtn = Gtk.Button(label="execute")
        execBtn.connect("clicked", self.on_button_clicked)
        grid.attach(execBtn, 3, 0, 1, 1)
        grid.set_column_spacing(5)
        self.add(grid)        

        entry = Gtk.Entry()
        entry.connect("key-release-event", self.on_key_release)

        grid.attach(entry, 0, 0, 3, 1)

        self.label = Gtk.Label("")
        self.label.set_width_chars(10)

        grid.attach(self.label, 1, 0.5, 1, 1)

        self.terminal_checkbutton = []
        self.terminal_name = {}
        for t in self.guake.notebook_manager.iter_terminals():
            cur_directory = t.get_current_directory()
            if cur_directory in self.terminal_name:
                self.terminal_name[cur_directory] = self.terminal_name[cur_directory] + 1
                checkBtn = Gtk.CheckButton(label= (cur_directory + ":[" + str(self.terminal_name[t.get_current_directory()])) + "]" )
            else:
                self.terminal_name[cur_directory] = 0
                checkBtn = Gtk.CheckButton(label=cur_directory)
            grid.attach_next_to(checkBtn,None,3,1,1)
            checkBtn.set_active(True)
            self.terminal_checkbutton.append({"button":checkBtn,"uuid":t.get_uuid()})

        self.set_border_width(5)

        self.set_title("Exexute in all terminals")
        self.set_default_size(300, 50)
        self.connect("destroy", self.on_finished)

    def on_key_release(self, widget,ev,data=None):
        self.text=widget.get_text()
        #エンターキーを回収
        if ev.keyval==Gdk.KEY_Return:
            self.on_button_clicked(widget)

    def on_button_clicked(self, widget):
        selected_terminal_uuid = self.select_terminal()
        self.guake.exec_multi(self.text,selected_terminal_uuid)
        self.on_finished(None)

    def on_finished(self,widget):
        self.hide()
        self.guake.show()
    
    def select_terminal(self):
        selected_terminal_uuid = []
        for checkBtn in self.terminal_checkbutton:
            if checkBtn["button"].get_active():
                selected_terminal_uuid.append(checkBtn["uuid"])

        return selected_terminal_uuid

このクラスを用いてウィンドウを作るのですが、その処理をguake_app.py内のGuake class内で呼び出しました。

def exec_multi_window(self):
        win = MultiExecWindow(self)
        self.hide()
        win.show_all()

Guake classには設定が呼び出されたりする時用にGuakeを閉じたり開いたりするメソッドが実装されていたため、そのメソッドを用いて設定と同じようなウィンドウ遷移をするようにしました。また、コマンドの複数タブにおける実行に関してはiter_terminalsというタブの情報を取得するものが存在していたため、これを流用し、execute_commandを用いて実行しました。

def exec_multi(self,command,selected_terminal_uuid):
        for t in self.notebook_manager.iter_terminals():
            if t.get_uuid() in selected_terminal_uuid:
                t.execute_command(command)

ショートカットの作成

guake/data/org.guake.gschema.xmlにショートカットの一覧が書いてあったのでそこに追加したいショートカットを追記すればうまく行くと思い追記します。

        <key name="exec-multi-window" type="s">
            <default>'&lt;Control&gt;&lt;Shift&gt;s'</default>
            <summary>execute in multi window</summary>
            <description>Accelerator to active function that exectute in multi window.</description>
        </key>

しかし、このままだとうまくショートカットが読み込まれません。どうやら、xmlファイルをコンパイルしたものを読んでいるらしいのでコンパイルしているコードを探します。

#guake/guake_app.py


class Guake(SimpleGladeApp):
    def __init__(self):
        ...
        
        try:
            #try_to_compile_glib_schemas()
            schema_source = load_schema()
        except GLib.Error:  # pylint: disable=catching-non-exception
            log.exception("Unable to load the GLib schema, try to compile it")
            try_to_compile_glib_schemas()
            schema_source = load_schema()
        self.settings = Settings(schema_source)

このように、Guakeクラスでスキーマコンパイルしようとしていることがわかるので、一度コンパイル済みスキーマファイルを削除することで例外を出し、再コンパイルさせます。

ショートカットのスキーマへの登録はできたので、次はショートカット時のイベントハンドラを登録していきます。

関連しているであろうコードはどこにあるのか探してみると、guake/keybindgins.pyというお誂え向けのファイルがあったのでそこを探してみると、

#guake/keybindings.py
class Keybindings():
    def __init__(self, guake):
        ...
        # Setup local keys
        keys = [
            'toggle-fullscreen', 'new-tab', 'new-tab-home', 'close-tab','save-tabs','exec-multi-window' 'rename-current-tab',
            ...
        ]
       
        for key in keys:
            guake.settings.keybindingsLocal.onChangedValue(key, self.reload_accelerators)
            self.reload_accelerators()
            
    ...
    def reload_accelerators(self, *args):
        ...
        self.load_accelerators()

    def load_accelerators(self):
        ...
        key, mask = Gtk.accelerator_parse(getk('new-tab'))
        if key > 0:
            self.accel_group.connect(key, mask, Gtk.AccelFlags.VISIBLE, self.guake.accel_add)


Keybindingsクラスのコンストラクタで、リストの要素をハンドラと結びつけていることがわかります。例えば上記のnew-tabという要素に対しては、Guakeクラスのaccel_add関数をハンドラとしていることがわかります。 このことから、xmlファイルに書いた名前を要素に追加して、それをハンドラに登録すればうまく行きそうなことが読み取れるので、追記します。(すでに上のリストにはexec_multi_windowという要素を追記してあります)

そして、load_accelerators関数にaccel_exec_multi関数というハンドラを結びつけるように追記します。

accel_exec_multi関数は、Guakeクラスのメンバ関数として実装します。

    def accel_exec_multi(self, *args):
        self.exec_multi_window()
        return True
    ...    
    def exec_multi_window(self):
        win = MultiExecWindow(self)
        self.hide()
        win.show_all()

結果として、Ctrl+Shift+sを押すことで作成したウィンドウが出るようになりました。

感想

  • 日頃から常用しているGuake Terminalのコードを実際に読み、コードを改変、追加することで機能を継いできたという経験ができとても刺激的でした。愛着もわいたのでばりばりGuakeを使っていきたいと思います。
  • 規模はそこまででもなかったが、このくらいの規模のソフトウェアをデバッグして、機能を追加するという経験はしたことがなかったので楽しかったです。
  • 全体を理解するのが困難である大きなプログラムに対して、必要な情報を見つけ出して所望の機能を実現するようにいじる、ということを通して、自分の中での既存のソフトウェアの中を除いて見ることへのハードルがかなり下がりました。

    参考

  • VteTerminal リファレンス
  • エスケープシーケンスチートシート