Штош. В прошлой статье мы сделали дизайн калькулятора. Ну а зачем нам этот голый дизайн без функционала, правильно?

Импортируем библиотеки, следуя стилю 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, несколько шорткатов для одной кнопки и обработаем ошибки. До встречи.


Репозиторий на GitHub

Комментарии (10)


  1. Mihahanya
    03.11.2021 21:04
    +1

    Ура! Теперь я знаю как написать калькулятор на пайтоне!


  1. Gengenid
    04.11.2021 10:28

    Это, конечно, скорее к первой части комментарий, но квадратным корнем лично я пользуюсь на порядок чаще отрицательных чисел.


    1. lesskop Автор
      04.11.2021 11:56

      Я же и написал в первой части, что функционал вы можете выбрать сами.


  1. QtRoS
    04.11.2021 10:45

    Хорошо бы где-то добавить дисклеймер, что так программировать функционал можно только в самых простых примерах. Отсутствие границы между UI и смысловой начинкой не позволяет писать тестируемый, поддерживаемый код.


    1. lesskop Автор
      04.11.2021 12:01
      +1

      Статья ориентирована на новичков. Я думаю, по мере увеличения объема и сложности проекта, идея создания границы между UI и смысловой начинкой сама попроситься в голову.


  1. Alex_Nevskiy
    04.11.2021 11:54

    Хотел взять фонт Rubik себе на заметку, но вот как он выглядит у вас в программе (слева) и вот как на сайте Google Fonts (справа) - разные шрифты!


    1. lesskop Автор
      04.11.2021 11:54

      На сайте Google Fonts:

      Не знаю, где вы так смотрели


      1. Alex_Nevskiy
        04.11.2021 12:03

        Смотрел по вашей ссылке, но, действительно, сейчас нормально Google Fonts показывает. Спасибо, все ok.


  1. mahonya
    08.11.2021 09:42

    попробуйте сделать 0.1+0.1+0.1=0.401

    или 0.1+0.1=+0.1=0.30000000000000


    1. lesskop Автор
      08.11.2021 10:51

      Это ошибка, связанная с вещественными числами на уровне их реализации. Расскажу об этом в третьей части. Так работает не только в Python, вот вам пример в JavaScript: