Штош. Дописываем калькулятор. Если вы не читали прошлую статью, я вам настоятельно рекомендую это сделать.

Добавляем отрицание

def negate(self) -> None:
	entry = self.ui.le_entry.text()

Логика проста: если отрицания нет в поле, значит добавляем. Иначе убираем левый символ с помощью среза [1:]. Не забываем ввести дополнительное условие для нуля.

if '-' not in entry:
	if entry != '0':
		entry = '-' + entry
	else:
		entry = entry[1:]
    
self.entry.setText(entry)

Возникает такая проблемка: при максимальной длине отрицание вытесняет последнюю цифру. Так работать, конечно же, не должно.

Давайте добавим в конструктор переменную максимальной длины поля ввода. Взять её можно с помощью метода maxLength.

self.entry_max_len = self.ui.le_entry.maxLength()

Если длина строки больше этой переменной на единицу и в строке есть отрицание, то ставим максимальную длину поля больше на единицу. Иначе ставим обратно дефолтную максимальную длину.

if len(entry) == self.entry_max_len + 1 and '-' in entry:
	self.entry.setMaxLength(self.entry_max_len + 1)
else:
	self.entry.setMaxLength(self.entry_max_len)

Backspace

Когда длина поля равна 1, нажатие на Backspace ставит в поле 0. Еще он ставит 0, когда в поле есть одна цифра с отрицанием. Во всех остальных случаях кнопка Backspace обрезает последний правый символ.

def backspace(self) -> None:
	entry = self.ui.le_entry.text()

	if len(entry) != 1:
		if len(entry) == 2 and '-' in entry:
			self.ui.le_entry.setText('0')
		else:
			self.ui.le_entry.setText(entry[:-1])
	else:
		self.ui.le_entry.setText('0')

Удаляем равенство из Label

Когда во временном выражении есть равенство, следующая нажатая кнопка должна удалять его из лейбла.

def clear_temp_if_equality(self) -> None:
	if self.get_math_sign() == '=':
		self.ui.lbl_temp.clear()

Но удалять должна не любая кнопка, а цифра, точка, отрицание, Backspace и очищение поля ввода.

Обрабатываем исключения

При нажатии на кнопку "равно", когда во временном выражении уже есть равенство, программа выкидывает KeyError.

Добавим в функцию вычисления конструкцию try-except. Вообще, в калькуляторе Windows оно продолжает считать, но у нас будет лучше - у нас ничего не будет происходить.

if temp:
	try:
		...
	except KeyError:
		pass

Куда же без ошибки деления на ноль - ZeroDivisionError. Напишем 2 переменные с текстом для показа ошибки.

error_zero_div = 'Division by zero'
error_undefined = 'Result is undefined'

Напишем метод для показа ошибки, передадим в него текст. Сначала ставим максимальную длину поля, равную длине текста ошибки, а затем уже ставим сам текст.

def show_error(self, text: str) -> None:
  self.ui.le_entry.setMaxLength(len(text))
  self.ui.le_entry.setText(text)

Если число в лейбле равно нулю, то ставим ошибку "результат не определен". Иначе ставим простое сообщение о делении на ноль.

except KeyError:
	pass

except ZeroDivisionError:
	if self.get_temp_num() == 0:
		self.show_error(error_undefined)
	else:
		self.show_error(error_zero_div)

Когда в лейбле есть 0 /, деление вызывает TypeError.

def math_operation(self) -> None:
  ...
    else:
      try:
        self.temp.setText(self.calculate() + f' {btn.text()} ')
        except TypeError:
          pass

Убираем ошибки

Если текст в поле равен какой-то ошибке, то ставим максимальную длину поля обратно к дефолтному значению и ставим текст 0

def remove_error(self) -> None:
  if self.ui.le_entry.text() in (error_undefined, error_zero_div):
    self.ui.le_entry.setMaxLength(self.entry_max_len)
    self.ui.le_entry.setText('0')

Убирать ошибку нужно в начале методов добавления цифры, backspace и очищения полей. Почему только они? Мы заблокируем кнопки знаков, точки и отрицания.

Блокируем кнопки

Для этого существует метод setDisabled, в который нужно передавать логическую переменную: чтобы заблокировать - True, а чтобы включить - False.

def disable_buttons(self) -> None:
  self.ui.btn_calc.setDisabled(True)
  self.ui.btn_add.setDisabled(True)
  self.ui.btn_sub.setDisabled(True)
  self.ui.btn_mul.setDisabled(True)
  self.ui.btn_div.setDisabled(True)
  self.ui.btn_neg.setDisabled(True)
  self.ui.btn_point.setDisabled(True)

Блокируем кнопки в конце метода показа ошибки.

Смотрите, на кнопки нельзя кликнуть, но по интерфейсу так сразу и не скажешь, пока не наведёшь. Нужно сделать текст кнопок серым.

Меняем цвет кнопок

Напишем метод изменения цвета кнопок. Мы будем передавать в него css строку с цветом.

def change_buttons_color(self, css_color: str) -> None:
  self.ui.btn_calc.setStyleSheet(css_color)
  self.ui.btn_add.setStyleSheet(css_color)
  self.ui.btn_sub.setStyleSheet(css_color)
  self.ui.btn_mul.setStyleSheet(css_color)
  self.ui.btn_div.setStyleSheet(css_color)
  self.ui.btn_neg.setStyleSheet(css_color)
  self.ui.btn_point.setStyleSheet(css_color)

Для блокировки у нас будет серый цвет #888.

def disable_buttons(self, disable: bool) -> None:
	...
  self.change_buttons_color('color: #888;')

Включаем кнопки

Передаем логическую переменную в метод блокировки и ставим её же в setDisabled для кнопок.

def disable_buttons(self, disable: bool) -> None:
  self.ui.btn_calc.setDisabled(disable)
  self.ui.btn_add.setDisabled(disable)
  self.ui.btn_sub.setDisabled(disable)
  self.ui.btn_mul.setDisabled(disable)
  self.ui.btn_div.setDisabled(disable)
  self.ui.btn_neg.setDisabled(disable)
  self.ui.btn_point.setDisabled(disable)

Еще нужно вернуть кнопкам белый цвет.

  color = 'color: #888;' if disable else 'color: white;'
  self.change_buttons_color(color)

Проставим в методы показа и удаления ошибки.

def show_error(self, text: str) -> None:
	...
  self.disable_buttons(True)
def remove_error(self) -> None:
  if self.ui.le_entry.text() in (error_undefined, error_zero_div):
    ...
    self.disable_buttons(False)

Регулируем размер шрифта

Для начала введем 2 переменные с размерами шрифтов:

default_font_size = 16
default_entry_font_size = 40

Теперь создадим методы получения ширины текста в пикселях для поля и лейбла:

def get_entry_text_width(self) -> int:
  return self.ui.le_entry.fontMetrics().boundingRect(
    self.ui.le_entry.text()).width()

def get_temp_text_width(self) -> int:
  return self.ui.lbl_temp.fontMetrics().boundingRect(
    self.ui.lbl_temp.text()).width()

Регулируем размер шрифта в поле ввода. Пока ширина текста больше ширины окна (-15, так будет лучше), мы уменьшаем размер шрифта на единицу.

def adjust_entry_font_size(self) -> None:
  font_size = default_entry_font_size
  
  while self.get_entry_text_width() > self.ui.le_entry.width() - 15:
    font_size -= 1
    
    self.ui.le_entry.setStyleSheet('font-size: ' + str(font_size) + 'pt; border: none;')
Нужно проставить этот метод после любого изменения длины текста в поле ввода.

Ставим после self.ui.le_entry.setText*

def add_digit(self):
  ...
  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())
    
	self.adjust_entry_font_size()
def add_point(self) -> None:
  self.clear_temp_if_equality()
  if '.' not in self.ui.le_entry.text():
    self.ui.le_entry.setText(self.ui.le_entry.text() + '.')
    self.adjust_entry_font_size()

И так далее..

Мы только уменьшаем размер шрифта, нужно его еще увеличивать при уменьшении ширины текста и увеличении ширины окна.

Пока ширина текста меньше ширины поля (-60, так будет лучше), увеличиваем размер шрифта, но не больше дефолтного значения.

def adjust_entry_font_size(self) -> None:
	...
	
  font_size = 1
  while self.get_entry_text_width() < self.ui.le_entry.width() - 60:
    font_size += 1

    if font_size > default_entry_font_size:
      break

    self.ui.le_entry.setStyleSheet(
      'font-size: ' + str(font_size) + 'pt; border: none;')

Как регулировать размер шрифта при изменении ширины окна приложения? Очень просто, нужно использовать встроенный resizeEvent:

def resizeEvent(self, event) -> None:
  self.adjust_entry_font_size()

Регулируем размер шрифта во временном выражении

То же самое проворачиваем для временного выражения.

Полный код метода
def adjust_temp_font_size(self) -> None:
  font_size = default_font_size
  while self.get_temp_text_width() > self.ui.lbl_temp.width() - 10:
    font_size -= 1
    self.ui.lbl_temp.setStyleSheet(
      'font-size: ' + str(font_size) + 'pt; color: #888;')

  font_size = 1
  while self.get_temp_text_width() < self.ui.lbl_temp.width() - 60:
    font_size += 1

    if font_size > default_font_size:
      break

    self.ui.lbl_temp.setStyleSheet(
      'font-size: ' + str(font_size) + 'pt; color: #888;')
Проставляем после изменения длины тексты в лейбле

Ставим после self.ui.lbl_temp.setText* и self.ui.lbl_temp.clear()

def add_temp(self) -> None:
  ...
  if not self.ui.lbl_temp.text() or self.get_math_sign() == '=':
    self.ui.lbl_temp.setText(entry + f' {btn.text()} ')
    self.adjust_temp_font_size()
		...
def clear_all(self) -> None:
  ...
  self.ui.lbl_temp.clear()
  self.adjust_temp_font_size()

И так далее...

Делаем код немного компактнее

Вообще это можно было сделать в самом начале, но мы сделаем в самом конце.

Заменим во всем коде:

  1. self.ui.le_entry на self.entry

  2. self.ui.lbl_temp на self.temp

Введем 2 переменные для поля и временного выражения в конструкторе класса:

self.entry = self.ui.le_entry
self.temp = self.ui.lbl_temp

Проблема с вычислениями вещественных чисел

Эта проблема не связана конкретно с питоном, она присутствует и в других языках, вот вам пример в JavaScript.

Все дело в том, что вещественные числа не могут быть точно представлены из-за особенностей их реализации в двоичном виде. Если вы заинтересовались темой, вы можете посмотреть про арифметику вещественных чисел и про стандарт 754.

Ну а как бороться с этой проблемой? Можно использовать модуль decimal, вот пример его работы:

from decimal import *

print('1.2 - 1 =', Decimal('1.2') - Decimal('1'))
print('3.4 + 4.3 =', Decimal('3.4') + Decimal('4.3'))
print('0.2 + 0.1 =', Decimal('0.2') + Decimal('0.1'))

1.2 - 1 = 0.2

3.4 + 4.3 = 7.7

0.2 + 0.1 = 0.3

Вот вам и задача со звездочкой, переделайте вычисления калькулятора с этим модулем.

Заключение

Конечно, можно найти еще уйму изъянов в работе калькулятора, но я и не претендую заменить системный. Зачем? Я думаю, получилось вполне неплохо относительно уже существующих туториалов на калькуляторы в интернетах, и надеюсь, что помог вам с изучением этого змеиного языка.


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

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


  1. sci_nov
    17.11.2021 20:21
    +1

    Несмотря на простоту калькулятора, его реализация не совсем простая: фактически это state machine.


  1. machinatororis
    17.11.2021 21:45

    Спасибо за статью! Мне очень полезно и актуально )


  1. zoldaten
    18.11.2021 09:56
    +1

    почему взяли pyside,а не qt6 ?


    1. lesskop Автор
      18.11.2021 14:49
      +1

      Не хочу распинаться про лицензии, быстродействие и остальное, поэтому отвечу просто - захотел PySide, сделал на PySide.

      Хотя сейчас прихожу к выводу, что для open-source проектов лучше использовать PyQt.


      1. zoldaten
        18.11.2021 15:50

        у меня были сложности при совмещении двух фреймворков. например, из окна, написанного на pyqt5 открыть окно, написанное на pyside5. пришлось переписать на pyqt5. было у вас что-то похожее ?


        1. lesskop Автор
          18.11.2021 16:03

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


          1. zoldaten
            18.11.2021 16:25

            допустим, есть окно-родитель на pyqt5 с кнопкой, при нажатии на которою, открывается дочернее окно на pyside5:

            окно

            # app.py
            import sys
            from PySide2.QtWidgets import (
                QWidget, QLineEdit, QScrollArea, QMainWindow,
                QApplication, QVBoxLayout, QSpacerItem, QSizePolicy, QCompleter
            )
            from PySide2.QtCore import Qt
            from customwidgets2 import OnOffWidget
            
            class MainWindow(QMainWindow):
                def __init__(self, *args, **kwargs):
                    super().__init__()
            
                    self.controls = QWidget()  # Controls container widget.
                    self.controlsLayout = QVBoxLayout()   # Controls container layout.
            
                    # List of names, widgets are stored in a dictionary by these keys.
                    #ряды
                    a = map(str, range(0, 10))
                    #print(a)
                    widget_names = a        
                    self.widgets = []
            
                    # Iterate the names, creating a new OnOffWidget for
                    # each one, adding it to the layout and
                    # and storing a reference in the self.widgets dict
                    for name in widget_names:
                        item = OnOffWidget(name)
                        self.controlsLayout.addWidget(item)
                        self.widgets.append(item)
            
                    spacer = QSpacerItem(1, 1, QSizePolicy.Minimum, QSizePolicy.Expanding)
                    self.controlsLayout.addItem(spacer)
                    self.controls.setLayout(self.controlsLayout)
            
                    # Scroll Area Properties.
                    self.scroll = QScrollArea()
                    self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
                    self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
                    self.scroll.setWidgetResizable(True)
                    self.scroll.setWidget(self.controls)
            
                    # Search bar.
                    self.searchbar = QLineEdit()
                    self.searchbar.textChanged.connect(self.update_display)
            
                    # Adding Completer.
                    self.completer = QCompleter(widget_names)
                    self.completer.setCaseSensitivity(Qt.CaseInsensitive)
                    self.searchbar.setCompleter(self.completer)
            
                    # Add the items to VBoxLayout (applied to container widget)
                    # which encompasses the whole window.
                    container = QWidget()
                    containerLayout = QVBoxLayout()
                    containerLayout.addWidget(self.searchbar)
                    containerLayout.addWidget(self.scroll)
            
                    container.setLayout(containerLayout)
                    self.setCentralWidget(container)
            
                    self.setGeometry(600, 100, 800, 600)
                    self.setWindowTitle('Инвентаризация. Работа.')
            
                def update_display(self, text):
            
                    for widget in self.widgets:
                        if text.lower() in widget.name.lower():
                            widget.show()
                        else:
                            widget.hide()
            
            
            app = QApplication(sys.argv)
            w = MainWindow()
            w.show()
            sys.exit(app.exec_())
            
            # customwidgets.py
            from PySide2.QtWidgets import (QWidget, QLabel, QPushButton,
                                         QHBoxLayout)
            
            class OnOffWidget(QWidget):
            
                def __init__(self, name):
                    super(OnOffWidget, self).__init__()
            
                    self.name = name
                    self.is_on = False
                    self.is_pause = True
                    self.is_off = False
            
                    self.lbl = QLabel(self.name)
                    self.lbl2 = QLabel('')
                    self.btn_on = QPushButton("Старт")
                    self.btn_pause = QPushButton("Пауза")
                    self.btn_off = QPushButton("Завершить ряд")
            
                    self.hbox = QHBoxLayout()
                    self.hbox.addWidget(self.lbl)
                    self.hbox.addWidget(self.lbl2)
                    self.hbox.addWidget(self.btn_on)
                    self.hbox.addWidget(self.btn_pause)
                    self.hbox.addWidget(self.btn_off)
            
                    self.btn_on.clicked.connect(self.on)
                    self.btn_pause.clicked.connect(self.on_pause)
                    self.btn_off.clicked.connect(self.off)
            
                    self.setLayout(self.hbox)
                    self.update_button_state()
            
                def show(self):
                    """
                    Show this widget, and all child widgets.
                    """
                    for w in [self, self.lbl, self.btn_on, self.btn_off]:
                        w.setVisible(True)
            
                def hide(self):
                    """
                    Hide this widget, and all child widgets.
                    """
                    for w in [self, self.lbl, self.btn_on, self.btn_off]:
                        w.setVisible(False)
                #действия кнопок
                def on(self): #старт
                    self.is_on = True
                    self.is_pause = False
                    self.is_off = False
                    self.update_button_state()
                    
                def off(self): #завершить ряд
                    self.is_on = False
                    self.is_pause = False
                    self.update_button_state()
                
                def on_pause(self): #пауза
                    self.is_pause = True
                    self.is_on = False
                    self.is_off = False
                    self.update_button_state()
            
                #обновление цвета кнопок согласно статуса
                def update_button_state(self):        
                    if self.is_on == True:
                        self.btn_on.setStyleSheet("background-color: #4CAF50; color: #fff;")
                        self.btn_pause.setStyleSheet("background-color: none; color: none;")
                        self.btn_off.setStyleSheet("background-color: none; color: none;")
                        self.lbl2.setText("В процессе") # self.name - № ряда
                    elif self.is_pause == True:
                        self.btn_on.setStyleSheet("background-color: none; color: none;")
                        self.btn_pause.setStyleSheet("background-color: #316ad3; color: #fff;")
                        self.btn_off.setStyleSheet("background-color: none; color: none;")
                        self.lbl2.setText("На паузе")
                    else:
                        self.btn_on.setStyleSheet("background-color: none; color: none;")
                        self.btn_pause.setStyleSheet("background-color: none; color: none;")
                        self.btn_off.setStyleSheet("background-color: #D32F2F; color: #fff;")
                        self.lbl2.setText("Ряд завершен")
            

            pyside5 приложение также использует class MainWindow(QMainWindow). А должно быть что-то вроде class Ui_object(object): для целей pyqt5. Попытка склеить эти два фреймворка.


            1. lesskop Автор
              18.11.2021 17:13

              К сожалению, не смогу вам помочь.


  1. Mavsent
    18.11.2021 12:02

    Статейка очень классная, но у меня сразу возникает вопрос, как работать с длинными числами Прошу прощения если скажу не правильно, но имел ввиду числа больше BigInteger.


    1. lesskop Автор
      18.11.2021 15:10
      +4

      В Python нет таких понятий, как big integer, long integer, есть только integer, и он поддерживает настолько длинные числа, насколько вы захотите.

      К сожалению, в калькуляторе из статьи есть проблемы с вычислениями длинных чисел, не реализовал правильно. Еще есть очевиднейшая проблема с обрезанием чисел до максимальной длины поля в 16 символов. Ее можно решить округлением, но тогда возникнет проблема с экспоненциальной записью числа. Да и вообще лучше переделать нативные вычисления.

      Посчитал, что уже и так достаточно сделал. Да и до сих пор так считаю :)


      1. Mavsent
        19.11.2021 03:02
        +2

        Вообще этого с головой хватит для написания подобного калькулятора новичку. Кто захочет сам запотеет и сделает остальное.