Штош. В прошлой статье мы сделали дизайн калькулятора. Ну а зачем нам этот голый дизайн без функционала, правильно?
Импортируем библиотеки, следуя стилю PEP 8:
import sys
from PySide6.QtWidgets import QApplication, QMainWindow
from design import Ui_MainWindow
Напишем дефолтный код для запуска любого Qt приложения с файлом дизайна. Если вам хочется подробнее узнать о работе каждой строчки кода, приглашаю посетить документацию.
class Calculator(QMainWindow):
def __init__(self):
super(Calculator, self).__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = Calculator()
window.show()
sys.exit(app.exec())
Если у вас не установлен в систему шрифт Rubik, то в вашем приложении шрифт будет дефолтным. Для решения этой проблемы не нужно устанавливать шрифт в систему. Импортируем:
from PySide6.QtGui import QFontDatabase
Теперь используем метод добавления шрифта приложения, в который передадим файл шрифта. Я сделал это в конструкторе класса.
QFontDatabase.addApplicationFont("fonts/Rubik-Regular.ttf")
Добавляем цифры
def add_digit(self):
btn = self.sender()
Метод sender() возвращает Qt объект, который посылает сигнал.
def sender(self): # real signature unknown; restored from __doc__
""" sender(self) -> PySide6.QtCore.QObject """
pass
В нашем случае сигнал является нажатием кнопки. Создадим кортеж с именами кнопок-цифр.
digit_buttons = ('btn_0', 'btn_1', 'btn_2', 'btn_3', 'btn_4',
'btn_5', 'btn_6', 'btn_7', 'btn_8', 'btn_9')
По дефолту в поле всегда стоит 0. В этом случае, если нажимается кнопка с цифрой, текст поля заменяется на эту цифру. Получается, что при нажатии на 0
ничего не будет происходить.
if btn.objectName() in digit_buttons:
if self.ui.le_entry.text() == '0':
self.ui.le_entry.setText(btn.text())
Если же в поле не 0
, то просто добавляем текст нажатой цифры в строку поля.
else:
self.ui.le_entry.setText(self.ui.le_entry.text() + btn.text())
Полный код метода добавления цифры
def add_digit(self):
btn = self.sender()
digit_buttons = ('btn_0', 'btn_1', 'btn_2', 'btn_3', 'btn_4',
'btn_5', 'btn_6', 'btn_7', 'btn_8', 'btn_9')
if btn.objectName() in digit_buttons:
if self.ui.le_entry.text() == '0':
self.ui.le_entry.setText(btn.text())
else:
self.ui.le_entry.setText(self.ui.le_entry.text() + btn.text())
Теперь нужно соединить нажатия кнопок с этим методом. Напишем в конструкторе класса.
# digits
self.ui.btn_0.clicked.connect(self.add_digit)
self.ui.btn_1.clicked.connect(self.add_digit)
self.ui.btn_2.clicked.connect(self.add_digit)
self.ui.btn_3.clicked.connect(self.add_digit)
self.ui.btn_4.clicked.connect(self.add_digit)
self.ui.btn_5.clicked.connect(self.add_digit)
self.ui.btn_6.clicked.connect(self.add_digit)
self.ui.btn_7.clicked.connect(self.add_digit)
self.ui.btn_8.clicked.connect(self.add_digit)
self.ui.btn_9.clicked.connect(self.add_digit)
Изначально был такой код. Но зачем передавать цифру-аргумент, если можно взять её из кнопки?
def add_digit(self, btn_text: str) -> None:
if self.ui.le_entry.text() == '0':
self.ui.le_entry.setText(btn_text)
else:
self.ui.le_entry.setText(self.ui.le_entry.text() + btn_text)
Соединения кнопок с методом
# digits
self.ui.btn_0.clicked.connect(lambda: self.add_digit('0'))
self.ui.btn_1.clicked.connect(lambda: self.add_digit('1'))
self.ui.btn_2.clicked.connect(lambda: self.add_digit('2'))
self.ui.btn_3.clicked.connect(lambda: self.add_digit('3'))
self.ui.btn_4.clicked.connect(lambda: self.add_digit('4'))
self.ui.btn_5.clicked.connect(lambda: self.add_digit('5'))
self.ui.btn_6.clicked.connect(lambda: self.add_digit('6'))
self.ui.btn_7.clicked.connect(lambda: self.add_digit('7'))
self.ui.btn_8.clicked.connect(lambda: self.add_digit('8'))
self.ui.btn_9.clicked.connect(lambda: self.add_digit('9'))
Посмотрим на результат.
Если вам режет глаз выход цифр за границы поля, потерпите. Мы решим эту проблему в следующей статье.
Очищаем Line Edit и Label
def clear_all(self) -> None:
self.ui.le_entry.setText('0')
self.ui.lbl_temp.clear()
Сделаем такой же метод для очистки только поля.
def clear_entry(self) -> None:
self.ui.le_entry.setText('0')
Соединяем.
# actions
self.ui.btn_clear.clicked.connect(self.clear_all)
self.ui.btn_ce.clicked.connect(self.clear_entry)
Добавляем точку
Почему вообще точка, а не запятая? Просто число с точкой можно сразу конвертировать в вещественное число, а с запятой придется еще менять знак. Да, мне лень.
Логика проста. Если точки нет в поле, значит добавляем.
def add_point(self) -> None:
if '.' not in self.ui.le_entry.text():
self.ui.le_entry.setText(self.ui.le_entry.text() + '.')
Соеди что? Правильно, няем.
self.ui.btn_point.clicked.connect(self.add_point)
Добавляем временное выражение
Что вообще оно из себя представляет? Есть два типа временных выражений:
1) Число и математический знак. Грубо говоря, это память калькулятора.
2) Равенство
def add_temp(self) -> None:
btn = self.sender()
Для начала нам нужно убедиться, что в лейбле нет текста. Затем ставим во временное выражение число из поля ввода + текст кнопки btn.
if not self.ui.lbl_temp.text():
self.ui.lbl_temp.setText(self.ui.le_entry.text() + f' {btn.text()} ')
Еще нужно очистить поле ввода. Полный код метода:
def add_temp(self) -> None:
btn = self.sender()
if not self.ui.lbl_temp.text():
self.ui.lbl_temp.setText(self.ui.le_entry.text() + f' {btn.text()} ')
self.ui.le_entry.setText('0')
Прикрутим пока одну кнопку сложения для теста.
self.ui.btn_add.clicked.connect(self.add_temp)
Точка и незначащие конечные нули не обрезаются.
Убираем незначащие конечные нули
Сделаем статический метод для решения этой проблемы. Передавать в функцию мы будем string число, получать то же самое.
@staticmethod
def remove_trailing_zeros(num: str) -> str:
Введем переменную n, которая приводит аргумент сначала к типу float, потом к string.
n = str(float(num))
Приведение к float обрезает нули, но не все. В конце остается .0
. Мы будем возвращать срез строки без двух последних символов, если они равны .0
, иначе будем возвращать просто n.
return n[:-2] if n[-2:] == '.0' else n
Полный код метода:
@staticmethod
def remove_trailing_zeros(num: str) -> str:
n = str(float(num))
return n[:-2] if n[-2:] == '.0' else n
Теперь добавим обрезку незначащих нулей в метод добавления временного выражения:
def add_temp(self) -> None:
btn = self.sender()
entry = self.remove_trailing_zeros(self.ui.le_entry.text())
if not self.ui.lbl_temp.text():
self.ui.lbl_temp.setText(entry + f' {btn.text()} ')
self.ui.le_entry.setText('0')
Старый код с передачей знака-аргумента
def add_temp(self, math_sign: str):
if not self.ui.lbl_temp.text() or self.get_math_sign() == '=':
self.ui.lbl_temp.setText(
self.remove_trailing_zeros(self.ui.le_entry.text()) + f' {math_sign} ')
self.ui.le_entry.setText('0')
Получаем число из Line Edit
Запишем в переменную текст поля, уберем потенциальную точку с помощью strip().
def get_entry_num(self):
entry = self.ui.le_entry.text().strip('.')
return float(entry) if '.' in entry else int(entry)
Возвращаем float, если точка есть в переменной, иначе возвращаем int, то есть целое число.
Добавим type hint к методу. Он может возвращать только целое или вещественное число. Для этого импортируем:
from typing import Union, Optional
Optional используем позже.
def get_entry_num(self) -> Union[int, float]:
В Python 3.10 не нужно ничего импортировать.
Можно просто написать
def get_entry_num(self) -> int | float:
Получаем число из Label
Если в лейбле есть текст, получаем его, разделяем по пробелам и берем первый элемент, то есть число.
def get_temp_num(self):
if self.ui.lbl_temp.text():
temp = self.ui.lbl_temp.text().strip('.').split()[0]
return float(temp) if '.' in temp else int(temp)
Type hint здесь - Union[int, float, None].
def get_temp_num(self) -> Union[int, float, None]:
Получаем знак из Label
Чтобы получить знак, нам нужно удостовериться в наличии текста в лейбле, затем получить текст из него, разделить по пробелам и вытащить последний элемент.
def get_math_sign(self):
if self.ui.lbl_temp.text():
return self.ui.lbl_temp.text().strip('.').split()[-1]
Type hint здесь - Optional[str]. Это означает, что метод может вернуть либо строку, либо ничего. Как Union[str, None], только компактнее и читабельнее.
def get_math_sign(self) -> Optional[str]:
Вычисляем выражение
Так, калькулятор же считать должен, я правильно понимаю? Ну тогда импортируем сложение, вычитание, умножение и деление из стандартной библиотеки operator.
from operator import add, sub, mul, truediv
Теперь создадим словарь с операциями. Каждому знаку присвоим его логическую функцию.
operations = {
'+': add,
'−': sub,
'×': mul,
'/': truediv
}
Создадим метод вычисления.
def calculate(self):
entry = self.ui.le_entry.text()
temp = self.ui.lbl_temp.text()
Если в лейбле есть текст, вводим переменную результата. Обрезаем конечные нули, приводим к строке. Берем операцию из словаря по знаку, в скобках указываем с какими числами провести операцию. Заметьте, что порядок передачи аргументов важен для деления и вычитания. Сначала мы передаем число из временного выражения, а потом из поля ввода.
if temp:
result = self.remove_trailing_zeros(
str(operations[self.get_math_sign()](self.get_temp_num(), self.get_entry_num())))
Добавляем в лейбл число из поля ввода и знак =
self.ui.lbl_temp.setText(temp + self.remove_trailing_zeros(entry) + ' =')
Ставим результат в поле ввода и возвращаем его.
self.ui.le_entry.setText(result)
return result
Type hint - Optional[str].
def calculate(self) -> Optional[str]:
Полный код метода вычисления
def calculate(self) -> Optional[str]:
entry = self.ui.le_entry.text()
temp = self.ui.lbl_temp.text()
if temp:
result = self.remove_trailing_zeros(
str(operations[self.get_math_sign()](self.get_temp_num(), self.get_entry_num())))
self.ui.lbl_temp.setText(temp + self.remove_trailing_zeros(entry) + ' =')
self.ui.le_entry.setText(result)
return result
Полный код метода вычисления
def calculate(self) -> Optional[str]:
entry = self.ui.le_entry.text()
temp = self.ui.lbl_temp.text()
if temp:
result = self.remove_trailing_zeros(
str(operations[self.get_math_sign()](self.get_temp_num(), self.get_entry_num())))
self.ui.lbl_temp.setText(temp + self.remove_trailing_zeros(entry) + ' =')
self.ui.le_entry.setText(result)
return result
Присоединяем.
Метод математической операции
def math_operation(self):
temp = self.ui.lbl_temp.text()
btn = self.sender()
Если в лейбле нет выражения, мы его добавляем, удивительно.
if not temp:
self.add_temp()
Если выражение есть, берем знак. Если он не равен знаку нажатой кнопки, то есть два случая. Первый - это равенство. В этом случае просто добавляем временное выражение. Иначе меняем знак выражения на знак нажатой кнопки.
else:
if self.get_math_sign() != btn.text():
if self.get_math_sign() == '=':
self.add_temp()
else:
self.ui.lbl_temp.setText(temp[:-2] + f'{btn.text()} ')
Если же знак равен знаку нажатой кнопки, то мы считаем выражение и добавляем в конец лейбла этот знак.
Полный код математической операции
def math_operation(self) -> None:
temp = self.ui.lbl_temp.text()
btn = self.sender()
if not temp:
self.add_temp()
else:
if self.get_math_sign() != btn.text():
if self.get_math_sign() == '=':
self.add_temp()
else:
self.ui.lbl_temp.setText(temp[:-2] + f'{btn.text()} ')
else:
self.ui.lbl_temp.setText(self.calculate() + f' {btn.text()}')
Полный код математической операции
def math_operation(self) -> None:
temp = self.ui.lbl_temp.text()
btn = self.sender()
if not temp:
self.add_temp()
else:
if self.get_math_sign() != btn.text():
if self.get_math_sign() == '=':
self.add_temp()
else:
self.ui.lbl_temp.setText(temp[:-2] + f'{btn.text()} ')
else:
self.ui.lbl_temp.setText(self.calculate() + f' {btn.text()}')
Соединяем.
self.ui.btn_add.clicked.connect(self.math_operation)
self.ui.btn_sub.clicked.connect(self.math_operation)
self.ui.btn_mul.clicked.connect(self.math_operation)
self.ui.btn_div.clicked.connect(self.math_operation)
Старый код метода вычисления со знаком-аргументом
def math_operation(self, math_sign: str):
temp = self.ui.lbl_temp.text()
if not temp:
self.add_temp(math_sign)
else:
if self.get_math_sign() != math_sign:
if self.get_math_sign() == '=':
self.add_temp(math_sign)
else:
self.ui.lbl_temp.setText(temp[:-2] + f'{math_sign} ')
else:
self.ui.lbl_temp.setText(self.calculate() + f' {math_sign}')
self.ui.btn_add.clicked.connect(lambda: self.math_operation('+'))
self.ui.btn_sub.clicked.connect(lambda: self.math_operation('−'))
self.ui.btn_mul.clicked.connect(lambda: self.math_operation('×'))
self.ui.btn_div.clicked.connect(lambda: self.math_operation('/'))
Помолимся за здравие Гвидо Ван Россума и запустим программу.
Почему-то не хочет дальше считать с равенством. А я вам расскажу почему. В методе добавления временного выражения нужно добавить дополнительное условие. В итоге получится "если временного выражения нет или есть равенство".
def add_temp(self) -> None:
btn = self.sender()
entry = self.remove_trailing_zeros(self.ui.le_entry.text())
if not self.ui.lbl_temp.text() or self.get_math_sign() == '=':
self.ui.lbl_temp.setText(entry + f' {btn.text()} ')
self.ui.le_entry.setText('0')
И вот еще покажу, как меняется знак, если вы постоянно промахиваетесь по кнопке.
Заключение
Штош, в следующей статье допишем калькулятор. Сделаем отрицание, backspace, несколько шорткатов для одной кнопки и обработаем ошибки. До встречи.
Комментарии (10)
QtRoS
04.11.2021 10:45Хорошо бы где-то добавить дисклеймер, что так программировать функционал можно только в самых простых примерах. Отсутствие границы между UI и смысловой начинкой не позволяет писать тестируемый, поддерживаемый код.
lesskop Автор
04.11.2021 12:01+1Статья ориентирована на новичков. Я думаю, по мере увеличения объема и сложности проекта, идея создания границы между UI и смысловой начинкой сама попроситься в голову.
Alex_Nevskiy
04.11.2021 11:54Хотел взять фонт Rubik себе на заметку, но вот как он выглядит у вас в программе (слева) и вот как на сайте Google Fonts (справа) - разные шрифты!
lesskop Автор
04.11.2021 11:54На сайте Google Fonts:
Не знаю, где вы так смотрели
Alex_Nevskiy
04.11.2021 12:03Смотрел по вашей ссылке, но, действительно, сейчас нормально Google Fonts показывает. Спасибо, все ok.
Mihahanya
Ура! Теперь я знаю как написать калькулятор на пайтоне!