quarta-feira, 6 de novembro de 2013

Eventos no PyQt: Criando Uma Calculadora

Neste artigo, vamos falar sobre as diferentes formas de trabalhar com eventos no PyQt. Para isso, nada melhor que exemplificar com uma aplicação que utiliza muitos eventos. Então, o objetivo será criar uma calculadora semelhante a esta:
 
Com essa quantidade de botões, não vão faltar eventos para lidar, certo?

TL;DR
Certo. Este é um artigo longo que tem como público-alvo programadores iniciantes que queiram conhecer um pouco mais sobre o PyQt através do desenvolvimento passo-a-passo de uma aplicação.
Se você já tem mais experiência e quer apenas ver o código final para poder testar por conta própria, você pode obter a classe da tela gerada pelo pyuic aqui e a subclasse que implementa as funcionalidades aqui. Divirta-se. :)

Eventualmente, tudo funciona


Como já escrevi em um artigo anterior, no PyQt os eventos são signals. Para saber quais signals cada componente suporta você pode utilizar a ferramenta de conexão de Signals e Slots do Qt Designer ou consultar a extensa documentação do PyQt.
Uma vez definido o signal desejado, é preciso utilizar o método connect para ligá-lo ao um slot.
Um slot é o receptor do signal. No PyQt, além dos componentes também é possível definir objetos do Python como slots. Assim, você pode associar o evento de um clique em um botão tanto à um campo de texto quanto à uma função do Python.

Criando a tela


Vamos começar. Para criar a calculadora usei, além de inúmeros botões, um componente bem bacana do PyQt que é o QLCDNumber. Depois da tela desenhada no Qt Designer, basta gerar a classe Python com o pyuic.
Uma dica útil para utilizar o pyuic no Linux sem precisar digitar todo o comando sempre que quiser gerar ou atualizar sua classe de tela.
Utilize o comando alias para criar um atalho, como no exemplo:
jonathan@linux:~$ alias pyuic="python3 /usr/lib/python3/dist-packages/PyQt4/uic/pyuic.py"
Lembrando que o caminho exato pode variar de acordo com a distribuição do Linux ou versão do Python/PyQt.
Agora você pode gerar a classe da tela com o alias:
jonathan@linux:~/Projeto> pyuic minhatela.ui -o minhaclasse.py
Neste caso, o alias será válido apenas durante esta sessão. Se quiser que fique salvo permanentemente, basta adicionar o comando acima no final do seu arquivo ~/.bashrc
Para ver o código gerado pelo pyuic para a tela que criei acesse este link.

Fazendo funcionar


O nome do arquivo gerado automaticamente pelo pyuic é tela.py. Dentro dele, temos a classe Ui_Janela, com todos os componentes que desenhei no Qt Designer.
Agora vamos criar uma subclasse que vai herdar todas as propriedades da classe Ui_Janela sem que precisemos alterar o arquivo onde ela está contida. 
Já escrevi sobre isso neste artigo.
O código básico para fazer a tela funcionar está no arquivo calc.py:
## Python 3
from PyQt4 import QtGui
import sys, tela
 
class Calc(QtGui.QMainWindow, tela.Ui_Janela):
    def __init__(self, parent=None):
        super(Calc, self).__init__(parent)
        self.setupUi(self)


if __name__ == '__main__':
    app = QtGui.QApplication(sys.argv)
    main_window = Calc()
    main_window.show()
    app.exec_()

Executando o código acima, já deve ser possível visualizar a tela criada. Embora ela não tenha nenhuma funcionalidade, já que ainda não definimos os eventos que farão a mágica acontecer.

Eventos


Vamos começar pelo evento mais simples. A tecla clear da calculadora, quando pressionada, deve limpar o visor da calculadora. Para isso, vamos adicionar na classe Calc o método evtLimparTela:
def evtLimparTela(self):
    self.lcd.display(0)

Agora queremos que o método acima seja chamado toda vez que a tecla C da calculadora é pressionada. Para isso vamos utilizar o método connect que vai ligar o signal do componente desejado ao nosso slot, que é o método evtLimparTela. O lugar ideal para fazer as conexões dos eventos é o método __init__ da própria classe. Assim, os eventos serão definidos logo que o objeto da classe for criado.
Uma das formas de fazer a conexão é esta:
self.btLimpar.clicked.connect(self.evtLimparTela)
Ou seja: nome do componente > nome do signal > connect > slot

As teclas numéricas


Nossa calculadora tem 10 teclas numéricas cuja única função, quando pressionadas, é adicionar o número correspondente no visor para ser calculado.
Uma forma de fazer isso é declarar 10 métodos, um para cada número e então conectar os 10 botões com os respectivos métodos. Mas convenhamos que isso não é nada prático.
Então está na hora de usar um pouco de mágica e testar todo o poder dos eventos no PyQt.
Na verdade o que vamos fazer é utilizar um único método para todas as teclas numéricas, chamado evtNumeros.
Isso vai poupar algumas linhas de código. Mas ainda nos resta fazer a conexão dos 10 botões com nosso super método. E para isso, também não queremos simplesmente repetir o mesmo comando 10 vezes.
Vamos utilizar um laço de repetição para fazer as conexões das teclas numéricas.
Se você observar a classe Ui_Janela, gerada automaticamente pelo pyuic, eu nomeei cada um dos botões numéricos com um padrão: btNum0, btNum1, btNum2...
Isso irá facilitar o nosso trabalho.
Observe o código abaixo:
for bt in self.__dict__:
    if bt.startswith("btNum"):
        getattr(self, bt).clicked.connect(self.evtNumeros)

Vamos ver com calma o que esse código faz:
  • O laço for vai iterar sobre o nome de todos os membros do objeto atual,  neste caso, todos os membros da classe Calc e aqueles herdados da classe Ui_Janela, que são os que nos interessam.
  • Depois verificamos se o nome do atributo inicia com o padrão que definimos.
  • Se inicia, pegamos o objeto pelo nome e conectamos o método evtNumeros ao signal clicked dele.
Agora, sempre que uma tecla numérica for pressionada, o mesmo método será chamado.
Vamos definir como esse método funciona.
A calculadora precisa armazenar o termo que está sendo inserido para realizar o cálculo, cada vez que um novo número é inserido, o termo precisa ser atualizado.
Então precisamos definir um atributo que armazene esse valor no método __init__ da classe Calc:
self.termo = ""
E o nosso método evtNumeros deve atualizar o termo com o número que foi inserido. Mas antes é preciso saber qual tecla foi pressionada, certo?
Como todas as teclas chamam o mesmo método, como saber qual número deve ser inserido?
Para obter o objeto que emitiu o signal o PyQt nos fornece o método sender(). E como temos o número que cada tecla representa no próprio nome do componente, fica fácil fazer a associação.
Veja o método:
def evtNumeros(self):
    self.termo += self.sender().objectName()[-1]
    self.lcd.display(int(self.termo))
Nós obtemos o objeto que emitiu o signal, isto é, o botão que foi pressionado. Depois pegamos o último caractere do nome dele (btNum0, btNum1, btNum2, lembra?) e concatenamos no termo.
Claro que além de atualizar o termo, precisamos atualizar o visor da calculadora. Embora o método display() também aceite strings, neste caso é importante converter o termo para inteiro e evitar que zeros não significativos sejam exibidos.

Chamando Doutor Hans Chucrute!


Hora de fazer uma operação! (Entendeu? Entendeu? [?]).
Tá bom, vamos recapitular. Abaixo está o código da nossa calculadora até agora:
## Python 3
from PyQt4 import QtGui
import sys, tela
 
class Calc(QtGui.QMainWindow, tela.Ui_Janela):
    def __init__(self, parent=None):
        super(Calc, self).__init__(parent)
        self.setupUi(self)
        
        self.termo = ""
        
        self.btLimpar.clicked.connect(self.evtLimparTela)
        for bt in self.__dict__.keys():
            if bt.startswith("btNum"):
                getattr(self, bt).clicked.connect(self.evtNumeros)
            
    def evtNumeros(self):
        self.termo += self.sender().objectName()[-1]
        self.lcd.display(int(self.termo))
    
    def evtLimparTela(self):
        self.termo = ""
        self.lcd.display(0)

 
if __name__ == '__main__':
    app = QtGui.QApplication(sys.argv)
    main_window = Calc()
    main_window.show()
    app.exec_()
Note que adicionei uma linha a mais no método evtLimparTela para que além de limpar o visor ele também limpe o termo da conta.
Executando o código acima já podemos digitar números e apagá-los depois, mas ainda não dá pra chamar de calculadora.
Precisamos definir as operações matemáticas.
Como no caso dos números, as operações também são semelhantes entre si e não justifica a necessidade de se criar métodos separados para cada uma.
Porém neste caso não podemos utilizar o mesmo truque de incluir a operação no nome do componente porque o Python não permite caracteres como + - / * em nomes de variáveis.
Então teremos que informar qual a operação em questão via parâmetro.
Mas não vimos como se faz isso ainda.
Até agora só trabalhamos com métodos sem parâmetros para receber nossos signals.
Mãos à obra.
Primeiro, temos um novo atributo para definir.
Qualquer calculadora que se preze deve ter a capacidade de fazer cálculos com múltiplos termos, mas a nossa só consegue armazenar um termo de cada vez.
Para resolver o problema, precisamos armazenar todo o conjunto de termos e as operações que os relacionam, o que costumamos chamar de expressão matemática.
self.expressao = []
E toda vez que uma tecla de operação é pressionada, devemos adicionar o termo atual na nossa expressão, juntamente com o símbolo da operação.
def evtOperacao(self, operacao):
    if self.termo:
        self.expressao.append(int(self.termo))
        self.expressao.append(operacao)
        self.termo = ""
O termo é inserido como inteiro também para evitar zeros não significativos que podem causar erros mais adiante, quando vamos resolver a expressão. O argumento operacao se refere ao símbolo da operação.
E não devemos esquecer de limpar o termo atual para que um novo número possa ser inserido.
Agora só falta fazer a conexão com os eventos de clique dos botões de operação.
A conexão precisa ser feita de forma que cada botão passe para o slot o parâmetro apropriado do símbolo da operação:
self.btDiv.clicked.connect(lambda: self.evtOperacao("/"))
self.btMult.clicked.connect(lambda: self.evtOperacao("*"))
self.btSoma.clicked.connect(lambda: self.evtOperacao("+"))
self.btSubtr.clicked.connect(lambda: self.evtOperacao("-"))
Lembre-se de que o método connect precisa receber um objeto callable. A função lambda é perfeita para esse caso. Quando o signal for emitido, a função lambda vai chamar o método evtOperacao com o parâmetro adequado.

Fazendo cálculos


Agora precisamos conectar o signal do botão de "=" com um método do Python que calcule a nossa expressão.
Como neste caso temos apenas uma tecla, um método e não precisamos passar nenhum parâmetro, basta utilizar o método connect simples:
self.btResultado.clicked.connect(self.evtResultado)

E o nosso método evtResultado é este:
def evtResultado(self):
        if self.termo and not self.resultado:
            self.expressao.append(int(self.termo))
            self.termo = str(int(eval("".join(map(str,self.expressao)))))
            self.expressao = [self.termo]
            self.lcd.display(self.termo)
            self.resultado = True
Quando a tecla "=" é pressionada, nossa calculadora vai fazer o seguinte:
  • Adicionar o termo atual à expressão
  • Avaliar a expressão utilizando a função eval do Python e transformar o resultado em nosso novo termo atual
  • Substituir a expressão por uma nova lista contendo apenas o resultado do cálculo
  • Atualizar o valor no visor da calculadora
No final do método definimos o valor True para o atributo resultado. Este atributo precisa ser definido no método __init__ da classe Calc, inicialmente com o valor False. Este atributo é uma flag que vai indicar se o termo atual é o resultado de um cálculo ou se foi inserido pelo usuário. 
Isso é necessário para que possamos tratar de um caso especial no funcionamento das calculadoras. Após realizar um cálculo, se uma tecla numérica for pressionada, significa que devemos iniciar uma nova expressão. Mas se ao invés disso, uma tecla de operação for pressionada, então devemos utilizar o resultado do cálculo como primeiro termo da nova expressão.
Para que isso funcione vamos precisar de algumas alterações nos métodos que já definimos.
Para facilitar o entendimento, vamos dar uma olhada no código completo até agora:

## Python 3
from PyQt4 import QtGui
import sys
import tela
 
class Calc(QtGui.QMainWindow, tela.Ui_Janela):
    def __init__(self, parent=None):
        super(Calc, self).__init__(parent)
        self.setupUi(self)
        
        self.termo = ""
        self.expressao = []
        self.resultado = False
        
        self.btLimpar.clicked.connect(self.evtLimparTela)
        self.btResultado.clicked.connect(self.evtResultado)
        
        # Conexão de eventos dos botões numéricos
        for bt in self.__dict__:
            if bt.startswith("btNum"):
                getattr(self, bt).clicked.connect(self.evtNumeros)
        
        # Conexão dos eventos dos botões das operações
        self.btDiv.clicked.connect(lambda: self.evtOperacao("/"))
        self.btMult.clicked.connect(lambda: self.evtOperacao("*"))
        self.btSoma.clicked.connect(lambda: self.evtOperacao("+"))
        self.btSubtr.clicked.connect(lambda: self.evtOperacao("-"))
            
    def evtNumeros(self):
        if self.resultado:
            self.expressao = []
            self.termo = self.sender().objectName()[-1]
            self.resultado = False
        else:
            self.termo += self.sender().objectName()[-1]
        self.lcd.display(int(self.termo))
    
    def evtOperacao(self, operacao):
        if self.termo:
            if self.resultado:
                self.resultado = False
            else:
                self.expressao.append(int(self.termo))
            self.expressao.append(operacao)
            self.termo = ""
    
    def evtLimparTela(self):
        self.termo = ""
        self.expressao = []
        self.lcd.display(0)
        
    def evtResultado(self):
        if self.termo and not self.resultado:
            self.expressao.append(int(self.termo))
            self.termo = str(int(eval("".join(map(str,self.expressao)))))
            self.expressao = [self.termo]
            self.lcd.display(self.termo)
            self.resultado = True
 
if __name__ == '__main__':
    app = QtGui.QApplication(sys.argv)
    main_window = Calc()
    main_window.show()
    app.exec_()
Note que o nosso método evtLimparTela precisou de ajustes desde a primeira vez que o escrevemos.
Mais uma vez, vamos ver com calma o que nossa calculadora está fazendo:
  • No método __init__ da classe Calc o atributo resultado é False e enquanto ele ficar assim, a calculadora vai funcionar normalmente como já definimos.
  • Após pressionarmos a tecla "=" para obter o resultado de um cálculo, mudamos o valor de resultado para True
  • Agora, se pressionarmos uma tecla de operação, a calculadora deve operar normalmente, pois já carregamos o termo atual e a expressão no método evtResultado. Só precisamos garantir que o termo não seja inserido de novo na expressão (definido no else)
  • Porém se resultado for True e uma tecla numérica for pressionada, o resultado obtido deve ser ignorado e uma nova expressão deve ser iniciada, descartando a atual e o termo.
  • De uma forma ou de outra, resultado volta a ser False.

Aprimorando 


Bom, a essa altura, a calculadora já está bem funcional. Vamos adicionar apenas mais um detalhe. Para que seu uso seja mais prático, além de permitir expressões com diversos termos, as calculadoras exibem o resultado parcial do cálculo à medida que as operações são realizadas.
Isso é bem simples de fazer.
Vamos criar um novo método para realizar o cálculo. Este novo método será chamado sempre que pressionarmos uma tecla de operação quando nossa expressão possuir pelo menos 2 termos.
O método calcular é quase o mesmo do método evtResultado:
def calcular(self):
        self.termo = str(int(eval("".join(map(str,self.expressao)))))
        self.expressao = [self.termo]
        self.lcd.display(self.termo)
Complementando, o método evtOperacao fica assim:
def evtOperacao(self, operacao):
        if self.termo:
            if not self.resultado:
                self.expressao.append(int(self.termo))
                if len(self.expressao) > 1:
                    self.calcular()
            else:
                self.resultado = False
            self.expressao.append(operacao)
            self.termo = ""
Apenas verificamos se a expressão possui mais de 1 termo (porque o símbolo da operação é adicionado só no final do método) e chamamos o método calcular.
Para finalizar, podemos modificar o método evtResultado para que ele também chame o método calcular, e assim eliminamos algumas linhas de código repetido:
def evtResultado(self):
        if self.termo and not self.resultado:
            self.expressao.append(int(self.termo))
            self.calcular()
            self.resultado = True
Muito bem!
Nossa longa jornada finalmente chegou ao fim.
Vamos juntar tudo o que vimos até agora e o código final da nossa super calculadora ficou assim:
## Python 3
from PyQt4 import QtGui
import sys
import tela
 
class Calc(QtGui.QMainWindow, tela.Ui_Janela):
    def __init__(self, parent=None):
        super(Calc, self).__init__(parent)
        self.setupUi(self)
        
        self.termo = ""
        self.expressao = []
        self.resultado = False
        
        self.btLimpar.clicked.connect(self.evtLimparTela)
        self.btResultado.clicked.connect(self.evtResultado)
        
        # Conexão de eventos dos botões numéricos
        for bt in self.__dict__:
            if bt.startswith("btNum"):
                getattr(self, bt).clicked.connect(self.evtNumeros)
        
        # Conexão dos eventos dos botões das operações
        self.btDiv.clicked.connect(lambda: self.evtOperacao("/"))
        self.btMult.clicked.connect(lambda: self.evtOperacao("*"))
        self.btSoma.clicked.connect(lambda: self.evtOperacao("+"))
        self.btSubtr.clicked.connect(lambda: self.evtOperacao("-"))
            
    def evtNumeros(self):
        if self.resultado:
            self.expressao = []
            self.termo = self.sender().objectName()[-1]
            self.resultado = False
        else:
            self.termo += self.sender().objectName()[-1]
        self.lcd.display(int(self.termo))
     
    def evtOperacao(self, operacao):
        if self.termo:
            if not self.resultado:
                self.expressao.append(int(self.termo))
                if len(self.expressao) > 1:
                    self.calcular()
            else:
                self.resultado = False
            self.expressao.append(operacao)
            self.termo = ""
     
    def evtLimparTela(self):
        self.termo = ""
        self.expressao = []
        self.lcd.display(0)
         
    def evtResultado(self):
        if self.termo and not self.resultado:
            self.expressao.append(int(self.termo))
            self.calcular()
            self.resultado = True
     
    def calcular(self):
        self.termo = str(int(eval("".join(map(str,self.expressao)))))
        self.expressao = [self.termo]
        self.lcd.display(self.termo)
 
if __name__ == '__main__':
    app = QtGui.QApplication(sys.argv)
    main_window = Calc()
    main_window.show()
    app.exec_()
Vale lembrar que o objetivo do projeto é praticar o uso de eventos no PyQt e que por isso deixamos de implementar recursos básicos de qualquer calculadora, como, por exemplo, o cálculo de números com ponto flutuante.
Procure modificar o código e realizar testes para aprimorar a calculadora e entender mais sobre signals e slots no PyQt.

Nenhum comentário:

Postar um comentário