この記事について
第二回、タイムミュートの実装はこちら。
本記事では、Linux向けTwitterクライアントのmikutter(作:としぁ)を二回に分けて改良していきます。
スクリプト言語Rubyで書かれたこのクライアントにどのような機能を追加するために、どんな道のりを辿って、どうやって峠を超えたかまでを出来る限り端折らずに書いてゆくつもりですので、どうか温かい目でお付き合いください。
ID検索機能を追加したい!
ここで言うID検索機能とは、検索ボックスに目当てのユーザID(登録時に個別に割り当てられる数字ではない。私の場合であれば@2kiymにあたる)を入力するとそのユーザのプロフィールがサクっと開く、というものです。
これは個人的にはとても重要な機能なのですが、mikutterにはデフォルトでは搭載されていませんでした。ちらっと探してみたところプラグインにも無かったような……? 気のせいかもしれませんが。ともかく、今回はmikutterにこの機能を追加するところまでを順を追って解説したいと思います。
そもそもどうやってデバッグするの
mikutterはRubyで書かれているということは先述の通りですが、自由課題初日にしてGUIを弄る人ならば誰もがぶち当たるであろう大きな壁に直面しました。
それは「デバッガで挙動を追えない」という問題です。
ブレークポイントを設定したはいいものの全く止まってくれません。そこで方向性を変えて「grepとprintデバッグ」で以降頑張ることとなりました。grepは偉大です。printも偉大です。
まずはプロフィールを表示するところから
mikutterではタイムラインのアイコンをクリックするとそのユーザのプロフィールタブが開かれます。この部分についてはID検索で実装したいものと全く同じなので、まずはプロフィールタブをどうすれば開けるのか、というところから調査してみます。
何はともあれgrepということで端末を開き、mikutterフォルダ内で
grep -r "プロフィール"
と検索をかけてやると、
plugin/gui/profile.rb:# プロフィールタブを提供するクラス
といういかにも怪しい結果が現れました。ファイル名からしてもこのコードを見てみて損はしないだろうと判断し、いざprofile.rbを開いてみます。上から数行眺めてみたところで、こんなコードが現れます。
Message::Entity.addlinkrule(:user_mentions, Message::MentionMatcher){ |segment|
idname = segment[:url].match(Message::MentionExactMatcher)[1]
user = User.findbyidname(idname)
if user
Plugin.call(:show_profile, Service.primary, user)
else
Thread.new{
user = service.scan(:user_show,
:no_auto_since_id => false,
:screen_name => idname)
Plugin.call(:show_profile, Service.primary, user) if user } end }
5行目と11行目を見てみると、どうやら「show_profileというプラグインを呼んでいる」ようです。第2引数は何だか厄介そうなのでとりあえず置いておいて、第3引数のuserは何なのでしょうか。4行目からすると「idnameを引数としてどこからか持ってきたユーザーオブジェクトを渡している」と推測できます。
ユーザーオブジェクトを渡してshow_profile……しかもfindbyidnameという非常に便利そうなメソッドまで見つかり、もしかすると検索ワードをこのメソッドに渡してオブジェクトを引っ張ってきて、プラグインを呼ぶだけで実装が終わるのではないか、という淡い期待が持てました(実際には後述の通りそんな簡単な話ではなかったのですが……)。
UIの実装
プロフィールが呼べたとしても、まずは検索したいIDを入力してもらうためにUIを実装しなければなりません。
mikutterウィンドウの右側にはデフォルトで装備されている検索タブがありますが、ひとまずはそこに新たな「ID検索」ボタンを追加し、クリックすると入力のためのダイアログが開く、というシンプルな実装を考えます。
検索タブに「検索」というボタンがあるのでひとまずgrepします。
grep -r "検索"
するとsearch.rbといういかにもなファイルがヒットするので開いてみると、中にはズラリと先ほどのボタン達の定義や、クリックされた時の挙動が書かれています。
ダイアログの生成やボタンの整列はmikutterのコードを模倣することで何とか実装できないこともないのですが、やはり使われているGUIライブラリであるGtk+について勉強すべきだと考え、いろいろとGoogle先生で漁ってみることにしました。以下、UIの実装については都度軽い説明を加えながら進みます。
Gtk+を用いた新しい要素を生成する場合は、基本的にGtk::(要素名).new
としてやれば大丈夫です。例えばテキストボックスであればGtk::Entry.new()
とすればよく、ボタンであればGtk::Button.new(_('ID検索'))
とします。
idsearchbtn.signal_connect('clicked'){ |elm|
dialog = Gtk::Dialog.new(_("ID検索 - %{mikutter}") % {mikutter: Environment::NAME}, nil, nil,[Gtk::Stock::OK, Gtk::Dialog::RESPONSE_ACCEPT],[Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_REJECT])
prompt = Gtk::Entry.new
dialog.vbox.
add(Gtk::HBox.new(false, 8).
add(Gtk::Label.new(_("IDを入力してください。(@は不要)"))).
add(prompt).show_all)
dialog.run{ |response|
if Gtk::Dialog::RESPONSE_ACCEPT == response
...
...
end
dialog.destroy
prompt = dialog = nil
}
}
上記がID検索のUIのベースとなる部分(ボタンをクリックしたら入力ボックスとボタンが置かれたダイアログが開く)なのですが、順を追って説明していきます。
まずこのコードには載っていない部分でidsearchbtn = Gtk::Button.new(_('ID検索'))
として新しいボタンを宣言しています。そのボタンがクリックされた時の処理を記述する、というのが一行目の意味ですが、この書き方についてはsearch.rbの別のボタンの実装そのままです。
次にダイアログの宣言ですが、OKボタンをACCEPTに、キャンセルボタンをREJECTに割り当てており、以下のif文で用いられます。この二種類以外にも様々な割り当てが存在しますが、今回はこれだけで十分です。
ボックスの説明に移ります。ボックスとはボタンやテキストボックスを格納できるひと塊のスペースを指し、Gtk+にはVbox(Vertical Box)とHbox(Horizontal Box)の二種類が存在します。その名の通り前者は垂直に、後者は水平に要素が並んでゆきます。また、ダイアログには初めから一つのVboxが付随しているようです。
今は説明文とテキストボックスを水平に並べたいので、新たにHboxを生成することにします。生成したものを既存のVboxの要素として追加し、さらにその新たなボックスに説明文とテキストボックスを追加しています。
こうして要素を整えてからdialogをrunすることで遂にダイアログが開きます。ここまで来ればUIの実装はほとんど終わりと言って良いでしょう。後は入力されたIDを読み取り、どうにかしてプロフィールタブを開いてやるだけです。
Userオブジェクトはいずこに
ダイアログの実装が済んだところで、初めに私が考えたのは以下のようなコードでした。
if Gtk::Dialog::RESPONSE_ACCEPT == response
inserted_id = prompt.text.to_s
getuser = User.findbyidname(inserted_id)
Plugin.call(:show_profile, Service.primary, getuser)
end
実際にこの方法で適当に思い浮かんだユーザIDを入力してみたところ、無事プロフィールタブが表示されました。つまり、Userオブジェクトを取ってきてshow_profileというプラグインに渡してやれば、あとはそれを呼ぶだけでプロフィールが開ける、という推測は当たっていたのです。
しかし喜んだのも束の間、重大な欠陥に気づきます。それは「フォローしていないユーザーを検索しようとすると落ちる」という問題です。正確にはフォローしていないのではなくTLに現れていない、と言った方が正確でしょう。つまり、findbyidnameメソッドで得られるのは「最近mikutter内で読み込まれたことがあるユーザー」のみだったのです。従ってフォローをしていなくとも、リツイートで回ってきたユーザー等は検索が成功しました。
となると、別の方法でUserオブジェクトを持ってこなければなりません。
APIを叩くしかない
findbyidnameの他に有用なメソッドがないか探してはみたものの、あまり良い結果は得られませんでした。mikutter内のブラックボックスが使えないと分かった今、最も手っ取り早い方法はTwitterAPIを直接叩くことです。ということで、次はmikutter内でAPIを叩くための箇所を探してみます。
grep -r "API"
とgrepしてみると、query.rbというファイルとquery!なるメソッドが見つかりました。その部分のコメントは以下の通り。
# APIを叩く
# ==== Args
# [method] メソッド。:get, :post, :put, :delete の何れか
# [api] APIの種類(文字列)
# [options]
# API引数。ただし、以下のキーは特別扱いされ、API引数からは除外される
# :head :: HTTPリクエストヘッダ(Hash)
# [force_oauth] 互換性のため
# ==== Return
# API戻り値(HTTPResponse)
# ==== Exceptions
# TimeoutError, MikuTwitter::Error
def query!(api, options = {}, force_oauth = false)
...
...
end
コメントに書いてあるのだから間違いありません。このメソッドでAPIを叩くことができるようです。第一引数にはAPIの名前を、第二引数にはそのAPI固有のオプションを指定すれば良さそうです。さて、準備が整ったところでsearch.rbでダイアログを開いた箇所に戻って以下のように書き足します。
Thread.new{
userdata = JSON.parse(Service.primary.query!('users/show', {:screen_name => inserted_id}).body)
getuser = User.new({ :id => userdata["id"],
:idname => userdata["screen_name"],
:name => userdata["name"],
:location => userdata["location"],
:detail => userdata["description"],
:profile_image_url => userdata["profile_image_url"],
:url => userdata["expanded_url"],
:protected => userdata["protected"],
:created => userdata["created_at"],
:followers_count => userdata["followers_count"],
:statuses_count => userdata["statuses_count"],
:favourites_count => userdata["favourites_count"],
:friends_count => userdata["friends_count"]})
Plugin.call(:show_profile, Service.primary, getuser)
}
まずはquery!内について。ユーザー情報を取得するAPIは"users/show"です。:screen_nameというステータスにユーザーIDを渡してやると、そのユーザーの情報がまとめてJSONで送られます。そして見ての通り、query!後はJSON.parseでパースしたものを新たなUserオブジェクトのステータスとして一つずつ代入するという非常に冗長なことをしています。
最終的にshow_profileをcallすることで、無事入力されたユーザーのプロフィールが開かれます。今度こそ、全く知らないユーザーでもmikutterが落ちることはありません。この部分については、query!を使わない、または自分でパースして代入しなくてもよい方法がmikutter内に用意されていたかもしれませんが、ひとまずはこれで動くには動きました。
さて、こうしてID検索機能を実装することが出来ました。次回はタイムミュート機能の実装を行います。