Pythonでコマンドラインランチャを作ろう!第九回−craftlaunch型の候補ウィンドウ

前置き

今日やるのは、自動補完の続きです。第七回でやってたcraftlaunch型の続きですね。
第七回では、打ち込んだ文字を補完するところまで行きました。
今回は、補完しなかったけれどヒットしたコマンドを、別ウィンドウで表示して、ということをします。

前回までは、

で、今回は

です。

大まかなやること

今回やる新しいことは、ウィンドウの大きさと位置の制御です。
候補ウィンドウを表示するときそこに表示する項目は、せいぜい3つか4つくらいでしかないと思います。だから、今までパスをリストウィンドウに表示してたみたいに無駄なスペースがあると見栄えが悪い。それに、今回は入力ボックスの上にコマンドリストを表示するというようにしたいのです。だから、大きさをヒットしたコマンドの数にしたがって調節しないと駄目な訳ですね。テキストコントロールと、リストボックスとがぴったりくっつくようにしないといけないのです。

あと、候補ウィンドウに表示される項目も、テキストボックスでの補完と同様、入力がされるたびに更新されるという形になる。wx.EVT_CHARを使って、打ち込まれるたびに云々、ということをするわけですね。で、カーソルキーの上が押されたらそれを表示する、と。そこでの細々としたことも扱います。

とりあえず、準備として第六回と同じくコマンドを記述したテキストファイルを用意してください。dataフォルダを、この実行ファイルと同じディレクトリに作り、その中にinstant.txtを作ってください。

exp05-ExcalipurHokanVersion

import wx,subprocess


class MyApp(wx.PySimpleApp):
    def OnInit(self):
        CMD_FILE = "data/instant.txt"
        self.Dict = {}
        f = open(CMD_FILE, "r")
        for x in f.readlines():
            line = x.split("=")
            key = line[0].strip()
            path = line[1].strip()
            self.Dict[key] = path
        f.close()
        self.typedtext = ""
        Frm = wx.Frame(None, -1, "wxPython", size=(200,45),pos = (400,600))
        self.TxtCtr = wx.TextCtrl(Frm, -1)
        self.TxtCtr.Bind(wx.EVT_CHAR,self.OnKeyChar)
        self.TxtCtr.Bind(wx.EVT_KEY_DOWN,self.OnKeyDown)
        self.CmdFrm = wx.Frame(None, 0, "wxPython", size=(100,25),pos = Frm.GetPosition() - (0,25),style=wx.DOUBLE_BORDER)
        self.LBox = wx.ListBox(self.CmdFrm, -1,size = (100,100))
        Frm.Show()
        return 1



    def OnKeyChar(self,event):
        key = event.GetKeyCode()
        if key >= 32 and key <= 127:
            self.TxtCtr.Clear()
            self.typedtext = self.typedtext + chr(key)
            wordcount = len(self.typedtext)
            itemlist = self.SearchList(self.typedtext)
            if itemlist != -1:
                item = itemlist[0]
                self.TxtCtr.SetValue(item)
                self.TxtCtr.SetSelection(wordcount , -1)
                itemlist.reverse()
                self.LBox.Set(itemlist)
                self.LBoxSetSize()
            else: pass
        elif key == wx.WXK_UP:
            if self.LBox.GetCount() <= 1:
                return
            else: pass
            self.CmdFrm.Show()
            self.TxtCtr.SetFocus()
            if self.LBox.GetSelection() == -1:
                self.LBox.SetSelection(0)
                self.LBox.SetSelection(self.LBox.GetCount() - 2)
            elif self.LBox.GetSelection() != -1:
                count = self.LBox.GetCount()
                next = self.LBox.GetSelection() - 1
                if next >=  0:
                    self.LBox.SetSelection(next)
                else:
                    self.LBox.SetSelection(count - 1)
            self.TxtCtr.SetValue(self.LBox.GetStringSelection())
            self.TxtCtr.SetSelection(len(self.typedtext) , -1)
            return
        elif key == wx.WXK_DOWN:
            if self.CmdFrm.IsShown() == -1:
                return
            else:
                count = self.LBox.GetCount()
                next = self.LBox.GetSelection() + 1
                if next < count:
                    self.LBox.SetSelection(next)
                else:
                    self.LBox.SetSelection(0)
                self.TxtCtr.SetValue(self.LBox.GetStringSelection())
                self.TxtCtr.SetSelection(len(self.typedtext) , -1)
        elif key == wx.WXK_BACK:
            self.LBox.SetSelection(-1)
            self.CmdFrm.Hide()
            self.TxtCtr.Clear()
            self.typedtext = self.typedtext[:-1]
            wordcount = len(self.typedtext)
            itemlist = self.SearchList(self.typedtext)
            if itemlist != -1:
                item = itemlist[0]
                self.TxtCtr.SetValue(item)
                self.TxtCtr.SetSelection(wordcount , -1)
                itemlist.reverse()
                self.LBox.Set(itemlist)
                self.LBoxSetSize()
            else: pass
        elif key in (wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER):
            cmd_name = self.TxtCtr.GetValue()
            path_file = self.Dict[cmd_name]
            subprocess.Popen(path_file)
            wx.Exit()
            return
            event.Skip()

    def LBoxSetSize(self):
        count = self.LBox.GetCount()
        plusalpha = (count - 1) * 14
        pos = (400, 575 - plusalpha)
        size = (100,25 + plusalpha)
        self.CmdFrm.SetPosition(pos)
        self.CmdFrm.SetSize(size)

    def SearchList(self,word):
        boxlist = []
        word = word.lower()
        length = len(word)
        for x in self.Dict.keys():
            x = x.lower()
            if x[:length] == word:
                boxlist.append(x)
        if boxlist ==[]:
            return -1
        return boxlist

    def OnKeyDown(self,event):
        key = event.GetKeyCode()
        if key ==  wx.WXK_ESCAPE:
            wx.Exit()
        else: event.Skip()

app = MyApp()
app.MainLoop()

何となくいつもは後ろにおいているのを先に出してみたけど、ながいねこれ^^;
一見複雑そうに見えるかもしれないけど、OnKeyCharでのキーの押しわけの分岐にいろいろくっつけたから長くなってるだけです。やっていることは第七回からはそれほど増えていない・・・はず。
ではそれぞれの説明を。

OnInitでCmdFrmフレームを作る

    def OnInit(self):
        CMD_FILE = "data/instant.txt"
        self.Dict = {}
        f = open(CMD_FILE, "r")
        for x in f.readlines():
            line = x.split("=")
            key = line[0].strip()
            path = line[1].strip()
            self.Dict[key] = path
        f.close()
        self.typedtext = ""
        Frm = wx.Frame(None, -1, "wxPython", size=(200,45),pos = (400,600))
        self.TxtCtr = wx.TextCtrl(Frm, -1)
        self.TxtCtr.Bind(wx.EVT_CHAR,self.OnKeyChar)
        self.TxtCtr.Bind(wx.EVT_KEY_DOWN,self.OnKeyDown)
        self.CmdFrm = wx.Frame(None, 0, "wxPython", size=(100,25),pos = Frm.GetPosition() - (0,25),style=wx.DOUBLE_BORDER)
        self.LBox = wx.ListBox(self.CmdFrm, -1,size = (100,100))
        Frm.Show()
        return 1

前回との違いは、

        self.CmdFrm = wx.Frame(None, 0, "wxPython", size=(100,25),pos = Frm.GetPosition() - (0,25),style=wx.DOUBLE_BORDER)
        self.LBox = wx.ListBox(self.CmdFrm, -1,size = (100,100))

です。今回、新たにウィンドウを表示させたいので、その分を付け足しているだけですね。フレームを作って、そこにListBoxをセットしています。
Frm.Show()で、テキストコントロールが入っているフレームは起動時に表示しますが、リストボックスのフレームのほうは表示してません。これにも一応注意しておいてください。

OnKeyCharで候補をリストボックスにセット

    def OnKeyChar(self,event):
        key = event.GetKeyCode()
        if key >= 32 and key <= 127:
            self.TxtCtr.Clear()
            self.typedtext = self.typedtext + chr(key)
            wordcount = len(self.typedtext)
            itemlist = self.SearchList(self.typedtext)
            if itemlist != -1:
                item = itemlist[0]
                self.TxtCtr.SetValue(item)
                self.TxtCtr.SetSelection(wordcount , -1)
                itemlist.reverse()
                self.LBox.Set(itemlist)
                self.LBoxSetSize()
            else: pass

今回新しく付け足しているのは、12〜14行目の

                itemlist.reverse()
                self.LBox.Set(itemlist)
                self.LBoxSetSize()

です。
テキストコントロールに表示する限りでは、コマンドのリストの一番目のみを取り出したらよかったのですが、リストボックスにはそれをすべてセットするわけですね。変数itemlistには、打ち込まれた文字にひっかかったコマンドがリストとして入っているのですが、それをitemlist.reverse()で逆順にしたあと、self.LBox.Set(itemlist)でリストボックスにセットしています。
itemlist.reverse()を使っているのは、カーソル移動をするときには逆順になっていたほうがぴったりくるからです。reverse()はリストを逆順にするものです。
あと、
self.LBoxSetSize()
について。この関数を呼び出すことでウィンドウを調整します。これは今回新しく作ったものですね。では、その説明を。

LBoxSetSizeでウィンドウの調整

    def LBoxSetSize(self):
        count = self.LBox.GetCount()
        plusalpha = (count - 1) * 14
        pos = (400, 575 - plusalpha)
        size = (100,25 + plusalpha)
        self.CmdFrm.SetPosition(pos)
        self.CmdFrm.SetSize(size)

まず、countでリストボックスの項目数を取得します。
この数から、サイズと位置とを調節します。
文字は一つで14だけ高さをとるから、文字の数に併せて候補ウィンドウの位置を上にずらし、ずらした分だけウィンドウを大きくしたら、テキストコントロールとリストボックスとがぴったりくっつくことになるわけです。項目数が一つ増えたらリストボックスを上に14だけ移動し、リストボックスの大きさを14だけ大きくする。これで、ウィンドウの調整をするわけです。

plusalpha = (count - 1) * 14

3行目のこの部分ですね。ここから、リストボックスの移動位置とサイズとを計算すると。それが4行目と5行目です。

        pos = (400, 575 - plusalpha)
        size = (100,25 + plusalpha)

ここですね。で、self.CmdFrm.SetPosition(pos)でここで計算した位置へリストボックス(というかそれのフレーム)を移動させ、self.CmdFrm.SetSize(size)で大きさを計算した大きさにしているわけです。
文字にしたらややこしいけど、つまりはこういうことです。

表示されるのがふたつの時はリストボックスの高さは28


表示されるのが四つの時はリストボックスの高さは56

いろいろな数値を入力していって、14が僕の作ってるランチャの場合には適切だということを発見しました^^
というわけで14という数値に必然性とかあまりないかもしれないので、各自適当に試してください。

カーソル上で二番目を選択

ここで扱っているような、craftlaunchのようなコマンド登録型ランチャの場合は、コマンドをfenrirのパスのように数千のなかから絞り込むということはあまりない。せいぜい登録するとしても100くらいが限界なのではないでしょうか。ということは、候補ボックスを出すまでもなく自動補完のみで目的のコマンドが絞り込まれる可能性が高い。それで、候補ボックスは普段は隠れていた方がいいわけですね。
そこで、カーソルの上を押した場合にボックスを表示し、選択をするという仕組みにする。そのためにOnKeyCharに、カーソル上を押したときの動作を付け足します。

        elif key == wx.WXK_UP:
            if self.LBox.GetCount() <= 1:
                return
            else: pass
            self.CmdFrm.Show()
            self.TxtCtr.SetFocus()
            if self.LBox.GetSelection() == -1:
                self.LBox.SetSelection(0)
                self.LBox.SetSelection(self.LBox.GetCount() - 2)
            elif self.LBox.GetSelection() != -1:
                count = self.LBox.GetCount()
                next = self.LBox.GetSelection() - 1
                if next >=  0:
                    self.LBox.SetSelection(next)
                else:
                    self.LBox.SetSelection(count - 1)
            self.TxtCtr.SetValue(self.LBox.GetStringSelection())
            self.TxtCtr.SetSelection(len(self.typedtext) , -1)
            return

候補ボックスが表示されていないときにカーソル上を押したら表示され、候補ボックスが表示されているときにカーソル上を押したら選択カーソルが一つ上に上がる。この二つのことができたらいいわけですね。
まあ順番に見ていきましょう。

2〜4行目−リストの項目数での処理
            if self.LBox.GetCount() <= 1:
                return
            else: pass

self.LBox.GetCount()でリストの項目数を取得しています。
ここでやっているのは、リストの中の項目数が1以下の時は何もしない、ということです。項目数が1だったらそもそも自動補完で十分で表示する必要ないし、0だったらそもそも表示できないですよね。

5〜6行目−候補ボックスの表示
            self.CmdFrm.Show()
            self.TxtCtr.SetFocus()

5行目のShow()で、このボックスを表示しています。最初の状態では候補ボックスは表示していないんでしたね。
6行目でテキストコントロールにフォーカスを当てています。2行目のself.CmdFrm.Show()で、候補ボックスの方にフォーカスが移ってしまうのでもとにもどしているわけです。

7〜9行目−候補ボックスが非表示の時の動作

7行目からが分岐です。まず、その最初から。これは、リストボックスでどれも選択されていないときに実行します。つまり、初めて上を押して候補ボックスを表示するとき、ここに書かれていることが実行されるわけですね。

            if self.LBox.GetSelection() == -1:
                self.LBox.SetSelection(0)
                self.LBox.SetSelection(self.LBox.GetCount() - 2)

self.LBox.GetSelection()というのは、リストボックスの選択状態を取得するもの。GetSelection()はリストボックスで選択されている項目の位置を数字で返しますが、もし何も選択されていなかったら-1を返します。すでにリストが表示されて選択カーソルが機能しているかどうか、というので分岐しているのです。
self.LBox.SetSelection(0)は一見意味がなさそうですが、これを入れていないとリストボックスの表示が変になってしまうので入れてます。もしかしたら僕の環境だけかもしれませんが。
次のself.LBox.SetSelection(self.LBox.GetCount() - 2)で、候補リストの下から二番目を選択しています。候補リストの一番下は、テキストコントロールで自動補完されていたコマンドだから、わざわざ候補ボックスを起動するという場合には選択しない可能性が高い。だから、下から二番目を選んでいるのです。

10〜16行目−候補ボックスが開いていて選択カーソルがある状態の時の動作

で、その次です。これは、リストが表示されていてどれかひとつが選択された状態のときに、実行されます。

            elif self.LBox.GetSelection() != -1:
                count = self.LBox.GetCount()
                next = self.LBox.GetSelection() - 1
                if next >=  0:
                    self.LBox.SetSelection(next)
                else:
                    self.LBox.SetSelection(count - 1)

self.LBox.GetSelection()は、今選択している箇所が上から何番目かを取得する。だから、これから1を引くとそれは今選択しているよりひとつ上の番号になるわけです。
if next >= 0:からの分岐は、選択カーソルがリストの一番上に行ったときにループするためにあります。
nextが0以上の場合は、普通に一つ上を選択状態にするだけでいい。self.LBox.SetSelection(next)ですね。
もし、nextが0よりも小さい場合は上の端まできたと言うことだから、そのときにはself.LBox.SetSelection(count - 1)で一番下を選択状態にする。これで、ループができるわけです。

17〜18行目−入力ボックスに反映

最後に、残った二行の説明を。

            self.TxtCtr.SetValue(self.LBox.GetStringSelection())
            self.TxtCtr.SetSelection(len(self.typedtext) , -1)

これはつまり、リストボックスの選択項目と同じものがテキストコントロールに表示されるようにしているわけですね。SetValueで、選択しているのと同じ文字列をテキストコントロールにセットし、SetSelectionでその文字列を自動補完っぽい選択状態にすると。

まとめ

そのほか、実行できるようにしてたりとか下を押したときの挙動とかバックスペース押したらとかいろいろ付け足してありますが、以前までにやったものばかりなのでパスします。バックスペースのとこも見たらわかるよね・・・?わかんなかったらいってくれれば僕の気が向けばどうにかするかもです。