Preciso criar uma aplicação GUI. Vamos criar em Ruby?

Por uma questão de praticidade, vou chamar ela de jeffgui.

Iniciando a gem

Bem, no post Criando uma Gem - SCREAM OUT!! eu criei na mão a gem. Mas posso usar outra alternativa:

bundle init

Isso gera o seguinte Gemfile:

# frozen_string_literal: true

source "https://rubygems.org"

# gem "rails"

Um placeholder para o arquivo de entrada em lib/jeffgui.rb:

# frozen_string_literal: true

module JeffGui
  class Error < StandardError; end
  # Your code goes here...
end

Como queremos usar GUI, adiciono a gem tk. Vou usar ela mais tarde. Finalmente, para a o resto das coisas básicas, bin/setup:

#!/usr/bin/env bash
set -euo pipefail
set -vx

bundle install

E o bin/console:

#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"
require "jeffgui"

# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start

require "irb"

IRB.start(__FILE__)

Não posso me esquecer de chmod u+x bin/*, e pronto, projeto iniciado. Só chamar o bin/setup para iniciar as coisas e bin/console para testar e…

Hmmm, deu problema. jeffgui não é uma gem válida. Ok, talvez seja só porque eu não executei em cima do bundler? Vamos ver:

$ bundle exec bin/console
bundler: failed to load command: bin/console (bin/console)
<internal:~/.asdf/installs/ruby/3.2.1/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:37:in `require': cannot load such file -- jeffgui (LoadError)
	from <internal:~/.asdf/installs/ruby/3.2.1/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:37:in `require'
	from bin/console:9:in `<top (required)>'
	from ~/.asdf/installs/ruby/3.2.1/lib/ruby/site_ruby/3.2.0/bundler/cli/exec.rb:58:in `load'

Hmmm, ainda não tá legal como eu imaginei que estaria. Pesquisei aqui por exemplos, para ver o que eu estava fazendo de errado. E eis que encontro essa referência, que direcionava para o diretório do hekla. E lá encontro isso aqui no bin/console:

#!/usr/bin/env ruby

require "bundler/setup"
Bundler.require

require "irb"
require "irb/completion"

DB = Sequel.connect(ENV["DATABASE_URL"] ||
  raise("missing_environment=DATABASE_URL"))

require_relative "../lib/hekla"

# Sinatra actually has a hook on `at_exit` that activates whenever it's
# included. This setting will suppress it.
set :run, false

IRB.start

Hmmm, require relativo para a gem então… Vamos lá! Alterando o meu bin/console:

#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"
require_relative "../lib/jeffgui"

# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start

require "irb"

IRB.start(__FILE__)

E executando:

› bundle exec bin/console
irb(main):001:0>

Ótimo, setup do projeto funcionou! Vamos começar a trabalhar!

Setup mínimo para tela e bloqueios

Peguei um projeto anterior que eu tinha feito no trabalho para adaptar para a nova aplicação GUI que vou fazer. Basicamente uma tela com um campo de texto e um botão:

module JeffGui
    class Gui
        def initialize
            @prepared = false
        end
        def prepare
            require 'tk'
            root = TkRoot.new { title "Ex1" }
            txt = TkText.new(root) {
              pack { padx 15 ; pady 15; side 'left' }
            }
            TkButton.new(root) {
                text 'Aperta!!'
                pack { padx 20 ; pady -20; side 'right' }
                command(proc {
                    v = txt.value
                    txt.value = ">>" + v + "<<"
                })
            }
            @prepared = true
        end
        def show
            prepare unless @prepared
            Tk.mainloop
        end
    end
end

Basicamente possibilita criar um objeto de interface gráfica e manipular ele antes de entrar no loop do tcl/tk (gui.prepare) e então exibir/entrar no loop principal do tcl/tk (gui.show).

Atributos de classe são sempre privados, e são indicados com o modificador de escopo @. Então, no construtor (initialize), temos que já iniciamos o valor @prepared = false. Então, no método show, iremos chamar prepare a não ser que ele já esteja preparado (prepare unles @prepared). Então seguimos com a chamada do main loop Tk.mainloop. Nenhum segredo.

Para prepare basicamente um boiler plate para por uma caixa de texto e um botão com uma ação (que consiste em colocar >> no começo do texto da caixa de texto e << no final do texto).

E vamos adaptar o arquivo central para que, caso ele seja chamado diretamente, suba a janela:

# frozen_string_literal: true

require_relative "jeffgui/gui"

module JeffGui
  class Error < StandardError; end
  # Your code goes here...

  if __FILE__ == $0
    gui = Gui.new
    gui.show
  end
end

Então vamos chamar em cima do bundler e…

$ bundle exec ruby lib/jeffgui.rb

Janela simples

Pronto, consegui levantar!

Mas uma coisa que eu curto muito no ruby é levantar via console para poder futricar as coisas ao vivo via IRB. Então, vamos lá:

#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"

require_relative "../lib/jeffgui"

# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start

require "irb"

$gui = JeffGui::Gui.new
$gui.show
IRB.start(__FILE__)

A variável com $ deixa ela com escopo global, então isso permite que eu a acesse dentro do IRB. Vamos rodar pra começar a macacar?

› bundle exec bin/console
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow should only be instantiated on the main thread!'

Hmmm, ué? Bem, vamos remover o show. irb abriu normalmente. Hmmm, e se a gente só preparar? Mudar o final do arquivo para isto daqui:

$gui = JeffGui::Gui.new
$gui.prepare
IRB.start(__FILE__)

Mesmo problema. E se eu deixar o final só assim? Removendo a chamada pro irb?

$gui = JeffGui::Gui.new
$gui.prepare
IRB.start(__FILE__)

Também mesmo problema… E se eu remover o require 'irb'? Bem, executou sem problemas…

Hmmmm, será que é algum problema do IRB com o Tcl/Tk implementado no Ruby? Vamos ver…

require 'irb'
require 'tk'

puts "aquiiiii"
# fim do arquivo

Resultado?

› bundle exec bin/console
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow should only be instantiated on the main thread!'

É, simplesmente importar dá problema. E se eu importar na ordem contrária?

require 'tk'
require 'irb'

puts "aquiiiii"
# fim do arquivo
› bundle exec bin/console
aquiiiii

Funcionou. Pronto, ufa. Preciso garantir que o tk seja importando antes do irb. Mas isso funciona mesmo? Com certeza? Bem, vamos ver. Ao chamar $gui.show eu fico com o processamento preso, portanto se eu quiser invocar o irb preciso iniciar esse processo antes do show. Mas chamar o IRB.start(__FILE__) também deixa o processo preso. Então? Vamos usar threads. Só por teimosia… vamos por o $gui.show na thread secundária?

#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"

require_relative "../lib/jeffgui"

# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start

require 'tk'

Thread.new do
    $gui = JeffGui::Gui.new
    $gui.show
end

require 'irb'
IRB.start(__FILE__)
› bundle exec bin/console
irb(main):001:0> #<Thread:0x0000000110110308 bin/console:14 run> terminated with exception (report_on_exception is true):
~/.asdf/installs/ruby/3.2.1/lib/ruby/gems/3.2.0/gems/tk-0.5.0/lib/tk/pack.rb:43:in `flatten': can't convert Tk::Text to Array (Tk::Text#to_ary gives String) (TypeError)

    args.flatten(1).each{|win| params.push(_epath(win))}
                 ^
	from ~/.asdf/installs/ruby/3.2.1/lib/ruby/gems/3.2.0/gems/tk-0.5.0/lib/tk/pack.rb:43:in `configure'
	from ~/.asdf/installs/ruby/3.2.1/lib/ruby/gems/3.2.0/gems/tk-0.5.0/lib/tk.rb:5098:in `pack'
	from ~/jeffgui/lib/jeffgui/gui.rb:10:in `block in prepare'
	from ~/.asdf/installs/ruby/3.2.1/lib/ruby/gems/3.2.0/gems/tk-0.5.0/lib/tk/text.rb:267:in `instance_exec'
	from ~/.asdf/installs/ruby/3.2.1/lib/ruby/gems/3.2.0/gems/tk-0.5.0/lib/tk/text.rb:267:in `new'
	from ~/jeffgui/lib/jeffgui/gui.rb:9:in `prepare'
	from ~/jeffgui/lib/jeffgui/gui.rb:23:in `show'
	from bin/console:55:in `block in <top (required)>'
irb(main):002:0>

A segunda linha do irb só foi exibida porque eu dei um para confirmar que ainda o ruby ainda estava no ar

Ok, ok, realmente o TK não gosta da ideia de ser cidadão em thread secundária. Entendi. Não vou mais insistir. Vou trocar as preocupações agora: iniciar primeiro $gui.prepare, então iniciar a thread do irb, e então vou dar o $gui.show:

#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"

require_relative "../lib/jeffgui"

# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start


$gui = JeffGui::Gui.new
$gui.prepare

Thread.new do
    require 'irb'
    IRB.start(__FILE__)
end

$gui.show

Rufem os tambores… Vamos testar…

› bundle exec bin/console
irb(main):001:0>

E a janela subiu. Pronto. Pronto? Bem, não, ainda preciso dar um jeito de passar comandos para a janela executar na main thread… sigh

Como posso fazer isso? Bem, uma maneira é ter um atributo acessível de $gui que permita eu injetar threads. Vou chamar ele de queue. Mas ruby não tem atributos visíveis externamente, apenas métodos… Pois bem, aí eu posso indicar pra ele pra criar um método para me enganar e trabalhar como se aquele atributo fosse público, usando o attr_read. Ficou assim o lib/jeffgui/gui.rb

module JeffGui
    class Gui
        attr_reader :queue
        def initialize
            @prepared = false
            @queue = Thread::Queue.new
        end
        def prepare
            require 'tk'
            root = TkRoot.new { title "Ex1" }
            txt = TkText.new(root) {
              pack { padx 15 ; pady 15; side 'left' }
            }
            TkButton.new(root) {
                text 'Aperta!!'
                pack { padx 20 ; pady -20; side 'right' }
                command(proc {
                    v = txt.value
                    txt.value = ">>" + v + "<<"
                })
            }
            @prepared = true
        end
        def show
            prepare unless @prepared
            Tk.mainloop
        end
    end
end

E com isso eu consigo via IRB enviar coisas para o objeto que contém a janela:

irb(main):016:1* $gui.queue << Proc.new do ||
irb(main):017:1*   puts "eu sou um outro print de debug"
irb(main):018:0> end
=> #<Thread::Queue:0x00000001051d00a0>
irb(main):019:1* $gui.queue << Proc.new do ||
irb(main):020:1*   puts "eu sou um outro print de debug"
irb(main):021:0> end
=> #<Thread::Queue:0x00000001051d00a0>
irb(main):022:0> $gui.queue
=> #<Thread::Queue:0x00000001051d00a0>
irb(main):023:0> $gui.queue.size
=> 2

Isso já dá pano pra manga para fazer as coisas. Por exemplo, eu não posso alterar as coisas via IRB, pois aí as alterações da tela não estariam na thread principal. Em compensação, eu posso mandar a alteração para a tela. Mas… ainda não consigo trabalhar ainda. Preciso de mais. Por exemplo, preciso de um ponto de entrada para por elementos no Tk. Tipo, se eu quiser um botão adicionado dinamicamente, algo assim:

Janela simples

Preciso fazer algo assim para ele:

TkButton.new(root) {
    text 'adicionado agora'
    pack { padx 20 ; pady -20; side 'right' }
}

Ou seja, preciso passar exatamente esse root como argumento. Então vamos fazer isso, deixar as funções passadas prontas para receberem root. Por sinal, podemos declarar blocos de funções a serem executados de outros modos do que apenas o Proc.new do |arg, list| end. O que fizemos anteriormente foi um Proc.new, mas podemos também fazer lambdas:

$gui.queue << lambda do |root|
  puts "eu sou um print de debug"
end
$gui.queue << -> (root) {
  puts "eu sou um outro print de debug"
}
$gui.queue << lambda { |root|
  puts "eu sou mais um outro print de debug"
}

Lambdas oferecem algumas vantagens perantes procs, como por exemplo em proc o return vai de fato retornar da função e não apenas parar o proc, já em lambda como a lambda é uma função o return tem efeito local. Você pode ler mais do assunto neste post.

Beleza, mas como podemos executar essas funções? Criando um botão cuja única função é executar esses comandos:

# dentro da classe JeffGui::Gui
def prepare
    require 'tk'
    @root = root = TkRoot.new { title "Ex1" }

    exec_queued = -> {
        unless @queue.empty? then exec_comandos_main_queue
        else puts "fila tá vazia"
        end
    }

    txt = TkText.new(root) {
        pack { padx 15 ; pady 15; side 'left' }
    }
    TkButton.new(root) {
        text 'Aperta!!'
        pack { padx 20 ; pady -20; side 'right' }
        command(proc {
            v = txt.value
            txt.value = ">>" + v + "<<"
        })
    }
    TkButton.new(root) {
        text 'comandos pendentes'
        pack { padx 20 ; pady -20; side 'right' }
        command(proc {
            exec_queued.call
        })
    }
    @prepared = true
end

private

def exec_comandos_main_queue
    until @queue.empty? do
        cmd = @queue.pop
        begin
            cmd.call @root
        rescue => e
            puts "oops, #{e}"
        end
    end
end

Algumas gotas de sintaxe e semântica do ruby

Bastante coisa acontecendo, deixa eu ir com calma… Primeiro ponto: eu preciso do elemento root para passar nas chamadas dos comandos da fila. Portanto eu guardo o @root = root = TkRoot.new {...}. Mantive o root como variável local pela simples conveniência de poder escrever root nos elementos visuais, não precisei trocar para @root.

Depois, o private. Basicamente private vai determinar que todas as declarações após ele não devem ser expostas fora do local em que ele se situa (módulo, classe). Aqui estou dizendo que a função exec_comandos_main_queue é privada da classe JeffGui::Gui.

Dentro da função, temos aqui um laço até algo ser verdade: until @queue.empty?. Da hora a expressividade do Ruby, né? Pois bem, nesse laço temos esse bloco:

begin
    cmd.call @root
rescue => e
    puts "oops, #{e}"
end

Aqui o begin marca uma região, que poderá ser resgatado caso ocorra alguma exceção. No meu caso, eu não especifiquei o topo da exceção para resgatar, mas é possível fazer isso com rescue SomeException => e. Vários resgates podem ser feitos com exceções distintas, e inclusive a exceção não precisa ser atribuída, apenas capturada no resgate. Um exemplo completamente arbitrário só para mostrar a sintaxe:

begin
  puts "olá"
rescue EOFError => e
  puts "eof?"
rescue NameError, Exception
 puts "hmmm?"
rescue => e
  puts "oh oooh"
end

Caso você queira fazer algo no final (independente de sucesso/falha, como o bloco finally do Java ou do JS), você pode se assegurar (ensure) que foi feito:

begin
  puts "sucesso"
ensure
  puts "no final das contas"
end

E você pode adicionar resgates também:

begin
  puts "sucesso"
rescue
  puts "falha"
ensure
  puts "no final das contas"
end

O bloco conforme foi desenhado permite executar tudo até o esgotamento da fila de execução. E quando há alguma falha, eu forneço um resgate, simples, mas ainda assim um resgate para não parar abruptamente a execução de outros comandos na fila:

until @queue.empty? do
    cmd = @queue.pop
    begin
        cmd.call @root
    rescue => e
        puts "oops, #{e}"
    end
end

Ok, agora por que não chamar esse método diretamente na ação do botão? Bem, minha primeira tentativa foi verificar por @queue, e ele reclamou que o atributo @queue não existia/estava nil. Eis minhas tentativas e seus resultados:

TkButton.new(root) {
    text 'comandos pendentes'
    pack { padx 20 ; pady -20; side 'right' }
    command(proc {
        puts @queue.size
    })
}

unknwon size

TkButton.new(root) {
    text 'comandos pendentes'
    pack { padx 20 ; pady -20; side 'right' }
    command(proc {
        exec_comandos_main_queue
    })
}

unknwon exec_comandos_main_queue

Como contorna isso? Eu não sei. Só sei que variáveis na clausura funciona, então criei um lambda para fazer essa chamada, e uso esse lambda na chamada:

exec_queued = -> {
    unless @queue.empty? then exec_comandos_main_queue
    else puts "fila tá vazia"
    end
}

# ...

TkButton.new(root) {
    text 'comandos pendentes'
    pack { padx 20 ; pady -20; side 'right' }
    command(proc {
        exec_queued.call
    })
}

Criando botão dinamicamente

Bem, agora que eu tenho a minha interface em Tk, a capacidade de passar comandos pra ela pra ser executados na thread principal e, principalmente, o IRB disponível para trabalhar, vamos começar a brincar com adicionar elementos na interface?

Vamos começar adicionando um botão com uma ação bobinha?

irb(main):001:1* $gui.queue << -> (root) {
irb(main):002:2*   TkButton.new(root) {
irb(main):003:2*     text "adicionado posteriormente"
irb(main):004:2*     pack {padx 20; pady -10; side 'right' }
irb(main):005:4*     command(proc {
irb(main):006:4*         puts "botão novo"
irb(main):007:2*         })
irb(main):008:1*   }
irb(main):009:0> }

Facilitando meu trabalho

Bem, uma coisa que estava particularmente me incomodando era o modo como se fazia para mandar um comando. Precisa chamar uma variável global chamada $gui e acessar um método dela e empurrar um valor… muita coisa.

A minha sensação era de que poderia ser mais simples. Então, e se eu declarasse uma função no console de modo que ficasse acessível pro IRB? Bem, por que não?

Cenário de teste, função hello:

def hello
    puts "oi oi"
end

require 'irb'
IRB.start(__FILE__)

E feliz, eu tinha o acesso a hello dentro do IRB. Assim sendo? Posso simplesmente definir a função para enfileirar a chamda: enqueue. Ela recebe um único argumento e passa para o $gui no campo queue chamando o operador de empurrar o argumento receber para dentro de queue:

def enqueue(cmd)
    $gui.queue << cmd
end

Temporizador

Bem, por mais que eu tenha conseguido chegar até aqui, eu poderia simplesmente esperar que o comando passado para a janela se executasse sozinho. Eu descobri o TkTimer, que faz isso que eu procura.

Pelo que encontrei em algumas fontes, você passa 2 argumentos e um bloco para esse TkTimer, e depois pede para que ele se execute. No caso, o primeiro argumento é o intervalo de tempo de repetição em mili segundos e o segundo é quantas vezes será repetido (onde -1 é infinitamente). Vamos testar aqui algo:

enqueue -> (root) {
    TkTimer.new(250, -1) {
        puts "oi?"
    }.start
}

Favor notar que o start é um método de TkTimer, e que ele está colocado após o bloco.

Ok, clicar no botão de “comandos pendentes” e… bem, uma enxurrada aqui de “oi?” “oi?” “oi?” sem fim. Executando o timer na thread principal tudo ficou bem tranquilo. Hora de testar no app. No método show, que é quando inicio o Tk.mainloop, vou antes iniciar esse contador. Não precisa ser imediato para mim, então 1 segundo de delay tá ótimo. Para alguns testes arbitrários eu precisei colocar um intervalor maior ou menor, então não estranhe se vir por aqui um intervalo de 10000, foi só um rascunho para provar um conceito que esqueci de normalizar o valor.

show ficou assim:

def show
    prepare unless @prepared
    TkTimer.new(1000, -1) do
        exec_comandos_main_queue
    end.start
    Tk.mainloop
end

Então a cada segundo vai ser tentado zerar a fila. Com isso, os comandos serão executados a cada segundo, tornando o botão de executar os comandos restantes desnecessários. Para provar que funciona, vamos por uma label:

enqueue -> (root) { TkLabel.new(root) { text "loucura"; pack {
 padx 20 ; pady -20; side 'right' }  }

E, voi là, o elemento está lá, no lugar. O loucura apareceu no lugar:

loucura

Geometrias, tutorial e mais enganos

Eu estava cometendo alguns erros que não estava satisfeito, mas também eu não sabia informar quais erros. Então fui atrás de mais informações e achei este ótimo tutorial da Tutorials Point.

Nele ele explica que você pode posicionar elementos e eles estarão dispostos de acordo com uma geometria. As geometrias são:

  • pack, que tentou-se usar aqui
  • grid, ideia de colocar coisas na grid mesmo, por coluna e linha
  • place, posicionamento mais “hardcore”

No pack, podemos selecionar o rumo que ele vai colocar. No caso, o padrão é o top. O que acontece quando colocamos múltiplos elementos no top? Bem, o primeiro elemento vai tocar na parte de cima do container. O segundo elemento vai naquele rumo de tocar na parte de cima, mas naquele caminho existe o primeiro elemento, então ele não vai se sobrepor a quem veio antes. E assim por diante.

De modo semelhante a left. O primeiro elemento adicionado com left vai ficar o mais a esquerda possível, e depois o segundo elemento vai ficar o mais a esquerda possível com exceção de quem entrou antes.

O padrão é top. Eu tentou criar duas caixas de texto, mas em cima dessas caixas de texto uma label sugestiva “coisa ali na {lado}”, onde {lado} pode ser ou “esquerda” ou “direita”. Primeira tentativa:

irb(main):014:1* enqueue -> (root) {
irb(main):015:2*   frame = TkFrame.new(root) {
irb(main):016:2*     pack { side 'top'}
irb(main):017:1*   }
irb(main):018:1*
irb(main):014:1* enqueue -> (root) {
irb(main):015:2*   frame = TkFrame.new(root) {
irb(main):016:2*     pack { side 'top'}
irb(main):017:1*   }
irb(main):018:1*
irb(main):019:2*   def lbl(titulo, rel_root)
irb(main):020:3*     mini_frame = TkFrame.new(rel_root) {
irb(main):021:3*       pack {side 'left'}
irb(main):022:2*     }
irb(main):023:3*     TkLabel.new(mini_frame) {
irb(main):024:3*       pack {side 'top' }
irb(main):025:3*       text titulo
irb(main):026:2*     }
irb(main):027:3*     TkText.new(mini_frame) {
irb(main):028:3*       pack {side 'top' }
irb(main):029:2*     }
irb(main):030:2*     mini_frame
irb(main):031:1*   end
irb(main):032:1*   lbl('coisa ali na esquerda', frame)
irb(main):033:1*   lbl('coisa ali na direita', frame)
irb(main):034:0> }

elementos ficaram todos como se fossem "top"

Bem, mas por quê? Vou pegar um exemplo do tutorial e ver a diferença:

f1 = TkFrame.new {
   relief 'sunken'
   borderwidth 3
   background "red"
   padx 15
   pady 20
   pack('side' => 'left')
}
f2 = TkFrame.new {
   relief 'groove'
   borderwidth 1
   background "yellow"
   padx 10
   pady 10
   pack('side' => 'right')
}

TkButton.new(f1) {
   text 'Button1'
   command {print "push button1!!\n"}
   pack('fill' => 'x')
}
TkButton.new(f1) {
   text 'Button2'
   command {print "push button2!!\n"}
   pack('fill' => 'x')
}
TkButton.new(f2) {
   text 'Quit'
   command 'exit'
   pack('fill' => 'x')
}
Tk.mainloop

Notou a diferença? No meu código eu passei um bloco para pack. E aparentemente esse bloco não foi executado (na real eu confirmei a não execução do bloco depois, tanto pela inexistência da “função” side como também pondo um puts). Só que pack não trabalha com bloco. pack vai trabalhar com argumento mapeado.

Entendido isso, vamos corrigir aquele código? Pelo menos posicionar os “mini frames” internos lado a lado?

irb(main):035:1* enqueue -> (root) {
irb(main):036:2*   frame = TkFrame.new(root) {
irb(main):037:2*     pack { side 'top'}
irb(main):038:1*   }
irb(main):039:1*
irb(main):040:2*   def lbl(titulo, rel_root)
irb(main):041:3*     mini_frame = TkFrame.new(rel_root) {
irb(main):042:3*       pack("side" => 'left')
irb(main):043:2*     }
irb(main):044:3*     TkLabel.new(mini_frame) {
irb(main):045:3*       pack {side 'top' }
irb(main):046:3*       text titulo
irb(main):047:2*     }
irb(main):048:3*     TkText.new(mini_frame) {
irb(main):049:3*       pack {side 'top' }
irb(main):050:2*     }
irb(main):051:2*     mini_frame
irb(main):052:1*   end
irb(main):053:1*   lbl('coisa ali na esquerda', frame)
irb(main):054:1*   lbl('coisa ali na direita', frame)
irb(main):055:0> }

novos elementos posicionados

Sim, só agora escrevendo o artigo pude perceber que ainda estou fazendo besteira com o pack. Ainda irei me acostumar com isso.

Componentes

Acabei usando ali componentes complexos, mas nem expliquei nada, só usei. Um TkFrame (tal qual o TkRoot também é um TkFrame para todos os efeitos) é um container que posso colocar coisas dentro. No meu caso, eu queria criar um componente composto por uma label e, abaixo dela, uma caixa de texto. De personalização é só o texto mesmo da label.

Como fazemos para criar um componente que reflete isso? O TkFrame vai servir de cola dos componentes, tal qual o <> ... </> que é comum em react. O frame só está ali para juntar componentes atômicos. Dentro desse container, eu coloco a label e em seguida o campo de texto. Recebo o título como argumento e devolvo o frame que está envolvendo os componentes (assim como o parent, receber o parent é uma necessidade no Tk):

def lbl(titulo, rel_root)
  mini_frame = TkFrame.new(rel_root) {
    pack("side" => 'left')
  }
  TkLabel.new(mini_frame) {
    pack("side" => 'top')
    text titulo
  }
  TkText.new(mini_frame) {
    pack("side" => 'top')
  }
  mini_frame
end

Claro que provavelmente você quer ter um acesso mais rico aos componentes internos, mas você pode inverter o controle e passar para o compoenente algo para que o componente possa dar alguma sinalização. Ou então permitir capturar os valores textuais para fazer algum trabalho com isso.

Ou então expor os componentes internos, usando um objeto intanciado localmente a partir de uma classe anônima:

def lbl(titulo, rel_root)
    mini_frame = TkFrame.new(rel_root) {
        pack("side" => 'left')
    }
    TkLabel.new(mini_frame) {
        pack("side" => 'top')
        text titulo
    }
    txtComponent = TkText.new(mini_frame) {
        pack("side" => 'top')
    }
    Class.new do
        def initialize(mini_frame, txtComponent)
            @mini_frame = mini_frame
            @txtComponent = txtComponent
        end
        def container
            @mini_frame
        end

        def txt
            @txtComponent
        end
    end.new mini_frame, txtComponent
end


# exemplo manipulando

esquerda = lbl("parada a esquerda", root)
esquerda.txt.value = "inserindo o valor"

Ok, mas eu posso brincar um pouco mais com isso. Posso resgatar o valor do texto como se ele fosse a variável principal. E também posso setar esse valor. Para tal, vou criar os métodos value e value=:

def lbl(titulo, rel_root)
    mini_frame = TkFrame.new(rel_root) {
        pack("side" => 'left')
    }
    TkLabel.new(mini_frame) {
        pack("side" => 'top')
        text titulo
    }
    txtComponent = TkText.new(mini_frame) {
        pack("side" => 'top')
    }
    Class.new do
        def initialize(mini_frame, txtComponent)
            @mini_frame = mini_frame
            @txtComponent = txtComponent
        end
        def container
            @mini_frame
        end

        def txt
            @txtComponent
        end
        def value
            @txtComponent.value
        end
        def value=(newValue)
            @txtComponent.value = newValue
        end
    end.new mini_frame, txtComponent
end


# exemplo manipulando

esquerda = lbl("parada a esquerda", root)
esquerda.value = "inserindo o valor"

Precisei exibir uma mensagem em um popup. Para ser a resposta de uma chamada. O problema do popup é que ele prende a thread principal até ser liberado, além de que no Mac ele não renderizou legal:

um simples popup

O código para gerar foi o seguinte (onde lhsComponent e rhsComponent são TkText).

Tk.messageBox(
    type: 'ok',
    icon: "info",
    title: "A random title",
    message: "over #{lhsComponent.value} as LHS and #{rhsComponent.value} as RHS",
    detail: 'detalhe'
)

Manipulando componente a partir de “wpath”

Assim como no DOM se tem o XPath, no Tk temos algo similar. Podemos inspecionar a partir de um elemento container os seus filhos. Eu previsava de dois campos textuais lado a lado, cada um com um título, e um botão de ação:

def prepare
    require 'tk'
    @root = root = TkRoot.new { title "Ex1" }

    def lbl(titulo, rel_root)
        mini_frame = TkFrame.new(rel_root) {
            pack("side" => 'left')
        }
        TkLabel.new(mini_frame) {
            pack("side" => 'top')
            text titulo
        }
        txtComponent = TkText.new(mini_frame) {
            pack("side" => 'top')
        }
        Class.new do
            def initialize(mini_frame, txtComponent)
                @mini_frame = mini_frame
                @txtComponent = txtComponent
            end
            def container
                @mini_frame
            end

            def value
                @txtComponent.value
            end

            def value=(newValue)
                @txtComponent.value = newValue
            end

            def txt
                @txtComponent
            end
        end.new mini_frame, txtComponent
    end

    mini_frame = TkFrame.new(root) {
        pack('side' => 'top')
    }

    lhsComponent = lbl('lhs', mini_frame)
    rhsComponent = lbl('rhs', mini_frame)

    submit = TkButton.new(root) {
        pack('side' => 'bottom')
        text "submit"
        command do
            Tk.messageBox(
                type: 'ok',
                icon: "info",
                title: "A random title",
                message: "over #{lhsComponent.value} as LHS and #{rhsComponent.value} as RHS",
                detail: 'detalhe'
            )
            puts "lhs text value #{lhsComponent.value}"
            puts "rhs text value #{rhsComponent.value}"
            lhsComponent.value = ">>#{lhsComponent.value}<<"
        end
    }

    @prepared = true
end

Então, para alterar o título do campo de texto a esquerda, precisei navegar através da raiz (um TkRoot, representado por $gui) assim:

irb(main):002:0> $gui.root
=> #<Tk::Root:0x0000000104a26b38 @path=".">
irb(main):003:0> $gui.root.winfo_children
=>
[#<Tk::Frame:0x0000000104b42940 @path=".w00000">,
 #<Tk::Button:0x0000000104c00f80 @path=".w00007">]
irb(main):004:0> $gui.root.winfo_children[0].winfo_children
=>
[#<Tk::Frame:0x0000000104c2d120 @path=".w00000.w00001">,
 #<Tk::Frame:0x0000000104ac1278 @path=".w00000.w00004">]
irb(main):005:0> $gui.root.winfo_children[0].winfo_children[0].winfo_children
=>
[#<Tk::Label:0x0000000104bceb70 @path=".w00000.w00001.w00002">,
 #<Tk::Text:0x00000001043e0850 @path=".w00000.w00001.w00003">]
irb(main):006:0> $gui.root.winfo_children[0].winfo_children[0].winfo_children[0]
=> #<Tk::Label:0x0000000104bceb70 @path=".w00000.w00001.w00002">
irb(main):007:0>

Vamos chamar esse carinha de label:

irb(main):011:0> label = $gui.root.winfo_children[0].winfo_children[0].winfo_children[0]
=> #<Tk::Label:0x0000000104bceb70 @path=".w00000.w00001.w00002">

Para alterar seu texto, só escrever no campo text. Como estou no IRB, precisei mandar para o enqueued:

irb(main):016:0> enqueue -> (root) { label.text = "esquerda" }

A outra label que eu queria mudar é do componente irmão desse. Ao analisar a descida na árvore, tem um lugar que tem como filhos dois Tk::Frames distintos:

irb(main):004:0> $gui.root.winfo_children[0].winfo_children
=>
[#<Tk::Frame:0x0000000104c2d120 @path=".w00000.w00001">,
 #<Tk::Frame:0x0000000104ac1278 @path=".w00000.w00004">]

Então, para localizar a outra label e alterar seu texto, só descer no componente irmão:

irb(main):021:0> label2 = $gui.root.winfo_children[0].winfo_children[1].winfo_children[0]
=> #<Tk::Label:0x0000000104c07100 @path=".w00000.w00004.w00005">

Em contraponto, a primeira label era esta:

irb(main):011:0> label = $gui.root.winfo_children[0].winfo_children[0].winfo_children[0]
=> #<Tk::Label:0x0000000104bceb70 @path=".w00000.w00001.w00002">

E usando a mesma estratégia pude mudar o text do outro título:

irb(main):022:0> enqueue -> (root) { label2.text = "direita" }

os componentes da tela

Fechando

O que foi mostrado aqui deve ser o suficiente para permitir uma experiência interativa para codar janelas em Tk usando Ruby. Como uma interface gráfica, Tk é muito mais do que apenas isso. Existem outras coisas a mais para lidar, como outras questões de posicionamentos e principalmente eventos. Mas acredito que o que foi mostrado aqui foi o suficiente para que se consiga adiantar bastante no uso disso.

Em cima de um canvas no Tk eu fiz um joguinho simples em Python: https://github.com/jeffque/games-tk-breakout. Note que no repositório o código é Python, mas a base de toda a interface gráfica é Tk, e portanto a parte de interface e comunicação coma lógica de negócio é a mesma, independente da lang que tem o Tk embarcado.