🐥

NimでWindowsアプリを作ってみた

2024/06/20に公開

GUIアプリの記事を書こうと思ったら、Nim2.0.6が出てしまったので、先にNim2.0.6の話を記載します。
Nim2.0.6は、2.0.4のバグを200件修正したパッチリリースになります。その後、Nimは2.2に移行するらしいです。Nim2.2の詳細は分かりませんが、恐らくException回りの変更とVSCodeのデバッカーでしょうかね?(知らんけど・・・)

では、話を戻しますが、今回取り扱うライブラリは、wNimと言うGUIライブラリです。
GUIアプリケーション用のライブラリは、いくつかありますが、Windowsに完全対応したツールが良いと思い使って見ました。
wNimは、winimと言うWindowsAPIをバインドしたライブラリを多様しており、作者も同一人物である事を考えて、これを使ってみました。
https://github.com/khchen/wNim

ただwNimライブラリですが、マクロなどを多数使っており、ソースを追っかける事が全くできません。
昨年までは、GUIアプリとして、Nimformsと言うライブラリを
使った簡単なGUIアプリを、趣味で作成していましたが、Nim2.0が出た事で、使えなくなりました。
原因は、ポインタの持ち方がNim1.6とNim2.0で違ってきた為だと思います。
※Nimformsは、イベント処理が関数ポインタ(onClickなど)を使用しているので、ソースを追いかけやすかった。
https://github.com/kcvinker/Nimforms

環境

私のPC環境はWindowsで動作させています。

  • OS: Windows11
  • Nim 2.0.6
  • wNim 0.13.3

wNimでGUIアプリを作ってみる

1. atlasで仮想環境にwNimを入れてみる

Nim2.0から、atlasコマンドは使えるようになります。
nimble install wNimでもいいのですが、それだと$HOME/.nimble内にwNimのライブラリが格納されるため、VSCodeでwNim内のexampleを動かすのが面倒なので、仮想環境構築ツールのatlasを使って見ました。
atlasの使い方については、この方が詳細に記載されています。

$ atlas init
$ atlas use wNim

プロジェクト直下でatlas initを実行すれば、同一フォルダ内に、atlas.workspaceファイルが作成されます。
また、atlas use wNimでgithub上のソースをプロジェクトフォルダ内にコピーされます。
その時に、wNim内で利用されるmemlib npeg winimなどのライブラリもコピーされます。
以下に、その時の構成を記載。

sample + atlas.workspace  ← init時に勝手に出来ます。workspaceの場所を示すファイルです。
       + packages + config.nims
                  + 以下:いっぱいのファイル
       + memlib   + memlib.nimble
                  + 以下:いっぱいのファイル
       + npeg     + npeg.nimble
                  + 以下:いっぱいのファイル
       + winim    + winim.nimble
                  + 以下:いっぱいのファイル
       + wNim     + wNim.nimble
                  + 以下:いっぱいのファイル
       + sample.nimble  ← 今回使わないから、どうでも良い
       + nim.cfg   ← 利用するライブラリのパスが指定されている

2. 超簡単なGUIアプリを作成

実際にソースを書いて、実行してみます。wNimのgithubに記載されているソースをそのまま実行してます。

sample01.nim
import wNim

# オブジェクトの設定
let app = App()
let frame = Frame(title="はじめてのwNim画面", size=(400, 300))

# 画面の中央に表示
frame.center()
frame.show()
app.mainLoop()

AppオブジェクトとFrameオブジェクトを呼び出し、画面表示するだけの簡単なプログラムです。

vscodeで実行
$ nim c -r -d:release -d:strip --opt:size --app:gui sample01.nim

-r はコンパイル後に、即時実行する事を意味し、
-d:release --opt:sizeは、コンパイル時の最適化指定で、
--app:guiは、コマンド実行と切り離して起動する事を意味します。
※-d:stripがわからなく、調べて見た結果、デバック情報を加えない指定みたいで、所謂最適化ですね。

  • 出力結果

3. GUIアプリにメニューと、フレーム内に文字とボタンを入れてみます。

ソースはwNim/examples/frame.nimをコピーし、sample02.nimとしました。

sample02.nim
import resource      
import wNim/[wApp, wIcon, wFont, wCursor, wAcceleratorTable, wFrame, wPanel,
  wMenu, wMenuBar, wButton, wStatusBar, wStaticText, wTextEntryDialog, wFontDialog]

# オブジェクトの設定
let app = App(wSystemDpiAware)
let frame = Frame(title="wNimのframe.nimを表示")
frame.dpiAutoScale:
  frame.minSize = (300, 200)
  frame.size = (600, 400)

let menuBar = MenuBar(frame)
let statusBar = StatusBar(frame)

let menu = Menu(menuBar, "&File")
menu.append(wIdExit, "終了", "ヘルプ機能:ステータスバーに終了が表示")

let accel = AcceleratorTable(frame)
accel.add(wAccelAlt, wKey_F4, wIdExit)

let panel = Panel(frame)
let staticText = StaticText(panel, label="フレームに日本語を表示")
staticText.font = Font(14, family=wFontFamilyDefault, weight=wFontWeightBold)
staticText.cursor = wHandCursor   # Textエリアにマウスが移動するとハンドカーソルに変わる
staticText.fit()

let btn = Button(panel, label="フォント")

# レイアウト処理
proc layout() =             # auto layout languageに準拠して表示処理
  panel.autolayout """
    HV:|-[staticText]->[btn]-|
  """

# イベント処理
staticText.wEvent_CommandLeftClick do ():
  let textEntryDialog = TextEntryDialog(frame, value=staticText.label,
    caption="Change The Text")

  if textEntryDialog.showModal() == wIdOk:
    staticText.label = textEntryDialog.value
    staticText.fit()
    staticText.refresh()

btn.wEvent_Button do ():
  let fontDialog = FontDialog(frame, staticText.font)
  fontDialog.color = staticText.foregroundColor
  fontDialog.enableSymbols(false)
  fontDialog.range = 0..24

  if fontDialog.showModal() == wIdOk:
    staticText.font = fontDialog.chosenFont
    staticText.foregroundColor = fontDialog.color
    staticText.fit()
    staticText.refresh()

frame.wIdExit do ():
  frame.close()

panel.wEvent_Size do ():
  layout()

# 画面の表示
layout()
frame.center()
frame.show()
app.mainLoop()

行数にすると60行程のソースで、テキスト表示、ボタン表示、メニュー制御、テキストダイアログ制御、フォントダイアログ制御、キー制御までを行えます。

import resourceについては、この後に説明します。上記ソースは、オブジェクトの生成とレイアウトの設定、イベント制御の3構成に別れます。

レイアウト制御は、layout()で画面を制御しており、これはアップル社のauto layout(Visual Format Language)を真似た言語ですが、完全に一致する物ではないですが、Visual Format Languageを若干拡張したような物です。
Visual Format Languageの詳細な説明は、Visual Format Languageを使う【Swift3.0】が参考になると思います。
また、autolayout.jsで操作結果の表示が可能です。
イベント制御では、オブジェクト名.イベントIDdo記法(無名プロシージャ)みたいな物で、全て制御し、そのイベントが発生した時に呼ばれる流れになります。
これってdo記法なのかな?、よくわかりません。 wNimのソースを確認したら、do記法(無名プロシージャ)でした。typeでproc()を別名にしていたので、見つけるのが困難ですね。

resource.nim
{.used.}
when defined(vcc):
  {.link: "wNim/examples/resource/wNimVcc.res".}

elif defined(cpu64):
  when defined(tcc):
    {.link: "wNim/examples/resource/wNimTcc64.res".}

  else:
    {.link: "wNim/examples/resource/wNim64.res".}

else:
  when defined(tcc):
    {.link: "wNim/examples/resource/wNimTcc32.res".}

  else:
    {.link: "wNim/examples/resource/wNim32.res".}

resource.nimファイル内のresファイル、windows APIで利用されるuser32.dllなどが一つにまとまった物と思われます。また、それぞれのresファイルはコンパイラの指定によって呼び出されるresファイルが異なる事を意味しています。
import wNimと記述すれば、リソースファイルも自動でロードされるのですが、必要ないソースまでコンパイルされるため、コンパイル時間もアプリケーションサイズも増えてきます。なので、リソースファイルを作成して必要なコンポーネントのみをimportする方がよいでしょう。

vscodeで実行
$ nim c -r -d:release -d:strip --opt:size --app:gui .\sample02.nim
  • 出力結果

3.1. Visual Format Language

Visual Format Languageについては、実際にソースを起動してみて試した方がわかりやすいと思います。
オブジェクト名.autolayoutが、記述されている部分が、Visual Format Languageになります。この他にも、layout DSL記述でも表示出来ますが、Visual Format Languageを理解出来れば、こちらの方が記述がわかりやすいかもしれません。

H:水平方向、V:縦方向、[オブジェクト名]で宣言したオブジェクトが表示、(長さ)でpixelまたは%表示で長さを表示、[オブジェクト名,オブジェクト名]で同じ長さ、~は均等を示します、outer:オブジェクト名ボックス内のオブジェクトを下に記載できる {stack1:[オブジェクト]}良くわからないがouterで分割した後に使うらしい。

簡単にボタンをレイアウトの配置としてWindowsアプリを作ってみました。

sample03.nim
import resource      
import wNim/[wApp, wFrame, wPanel,
  wMenu, wMenuBar, wButton, wStatusBar]

# オブジェクトの設定
let 
  app = App(wSystemDpiAware)
  frame = Frame(title="autolayoutを表示", size=(600,400))
  menuBar = MenuBar(frame)
  statusBar = StatusBar(frame)
  menu = Menu(menuBar, "ファイル")
  panel = Panel(frame)
  btn1 = Button(panel, label="ボタン1")
  btn2 = Button(panel, label="ボタン2")

menu.append(wIdExit, "終了", "ヘルプ機能:ステータスバーに終了が表示")

# レイアウト処理
proc layout() =             # auto layout languageによって表現されている
  panel.autolayout """
    H:|-[btn1(20%)]-10-[btn2]-|
    V:|-[btn1,btn2]-|
  """
#[
  1行目水平方向にbtn1とbtn2の間に10pixel離して配置、btn1は水平方向に20%の幅
  2行目縦方向にbtn1とbtn2を同じ高さで配置
]#

# イベント処理
frame.wIdExit do ():      # 終了制御
  frame.close()

panel.wEvent_Size do ():  # リサイズ制御
  layout()

# 画面の表示
layout()
frame.center()
frame.show()
app.mainLoop()
  • 出力結果

次に、3つのボタンで横と高さを表示したWindowsアプリになります。

sample04.nim
import resource      
import wNim/[wApp, wFrame, wPanel,
  wMenu, wMenuBar, wButton, wStatusBar]

# オブジェクトの設定
let 
  app = App(wSystemDpiAware)
  frame = Frame(title="autolayoutを表示", size=(600,400))
  menuBar = MenuBar(frame)
  statusBar = StatusBar(frame)
  menu = Menu(menuBar, "ファイル")
  panel = Panel(frame)
  btn1 = Button(panel, label="ボタン1")
  btn2 = Button(panel, label="ボタン2")
  btn3 = Button(panel, label="ボタン3")

menu.append(wIdExit, "終了", "ヘルプ機能:ステータスバーに終了が表示")

# レイアウト処理
proc layout() =             # auto layout languageによって表現されている
  panel.autolayout """
    H:|-[btn1(20%)]-[btn2,btn3]-|
    V:|-[btn1,btn2]-|
    V:|-[btn2(80%)]-[btn3]-|
  """

# イベント処理
frame.wIdExit do ():      # 終了制御
  frame.close()

panel.wEvent_Size do ():  # リサイズ制御
  layout()

# 画面の表示
layout()
frame.center()
frame.show()
app.mainLoop()
  • 出力結果

最後に、トップに入力欄を集めて表示させてWindowsアプリになります。
outer:で分割して横と縦を表示させています。

sample05.nim
import resource      
import wNim/[wApp, wFrame, wPanel, wStaticText, wTextCtrl, wStaticBox, wListBox,
  wMenu, wMenuBar, wButton, wStatusBar]

# オブジェクトの設定
let 
  app = App(wSystemDpiAware)
  frame = Frame(title="autolayoutを表示", size=(600,400))
  menuBar = MenuBar(frame)
  statusBar = StatusBar(frame)
  menu = Menu(menuBar, "ファイル")
  panel = Panel(frame)
  box1 = StaticBox(panel, label="入力欄")
  text1 = StaticText(panel, label="固定文字表示")
  textc1 = TextCtrl(panel, value="入力してね!", style=wBorderSunken)
  btn1 = Button(panel, label="押せ")
  list1 = ListBox(panel, style=wLbNoSel or wBorderSimple or wLbNeededScroll)

menu.append(wIdExit, "終了", "ヘルプ機能:ステータスバーに終了が表示")

# レイアウト処理
proc layout() =             # auto layout languageによって表現されている
  panel.autolayout """
    H:|-[box1,list1]-|
    V:|-[box1]-[list1]-|
    outer: box1
    H:|-5-{stack1:[text1]-[textc1]-[btn1(50)]}-5-|
    V:|-5-[stack1]-5-|
  """

# イベント処理
frame.wIdExit do ():      # 終了制御
  frame.close()

panel.wEvent_Size do ():  # リサイズ制御
  layout()

btn1.wEvent_Button do (): # ボタンが押された時のイベント制御
  let str = textc1.getValue()
  list1.append(str)

# 画面の表示
layout()
frame.center()
frame.show()
app.mainLoop()
  • 出力結果

3.2. イベント制御

イベント制御は、オブジェクト名.イベントID do()で制御できます。

イベントID 内容
wEvent_Button ボタン押下時
wIdExit 画面終了時
wEvent_Size 画面のリサイズ時
wEvent_Timer タイマーイベント
wEvent_MouseEnter マウス押下時
wEvent_MouseHover マウス移動時
wEvent_MouseLeave マウス押下後

※その他、多数のイベント制御が可能

4. wNimのexampleを実行してみる

$ cd wNim/examples
$ nimble tasks     ← タスクの一覧が表示され、その中にexampleも含まれます。
$ nimble example   ← 実行するとwNim/exampleの全てのサンプルがコンパイルされます。

※nimble exampleを実行すると、結局は$HOME/.nimbleにwNimライブラリが入ってしまいますけど・・・

Nim2.0.6でコンパイルをすると、example/reversi.nimでコンパイルエラーになります。(Nim2.0.4ではコンパイルは通りますが・・・)

理由は、thread用のソース(example/mcts/mcts.nim)内をimportしていないのが原因です。
多分、別のサンプルでthread用のソースを共有したいがために、メイン側でimportを記載してthread用のソースには記載していないのが原因です。
Nim2.0.6はより厳格なチェックをしているため、曖昧な部分はエラーにしてるんでしょうね。
example/reversi.nimをコンパイルするのであれば、example/mcts/mcts.nimにimport engine_reversiを記入すればOKです。
但し、その後のtictactoe.nimでも同様のエラーが出ると思いますので、その時は、import engine_tictactoeと記述を変更しなくてはいけません。

まあ、exampleフォルダ内のサンプルは、どう動くかのサンプルなので、ソースを見て確認する物だと思って貰えればいいかと思います。

おわりに

wNimは、コンパイルこそ少し時間がかかりますが、動作自体は速く、何より行数が少ない事もあり、プログラムしやすいGUIライブラリだと思います。
ただし、前述した通り、wNim自体複雑過ぎるので、wNim自体でバグが出たりした場合には、ソースを追っかけるのが至難ではあります。
(個人的にはNimformsの方が、わかりやすくて好きですがね)
ただ、昨年のnimフォーラムでnim作者がNim純正のWindowシステムを探していたので、近いうちにPythonのようなTkinterみたいなのが出てくる可能性は高いです。
(次のNim2.2でGUIライブラリが標準で付いたりする・・・かも)

Discussion