Перейти к основному содержимому

SmallDesktop на Morphic — практикум

Разработчику Архитектору

О чём эта статья

Пошаговый практикум: вы соберёте настольное приложение в Pharo на Morphic — с боковой панелью, несколькими экранами, полем ввода, кнопками и строкой статуса. Это типичный каркас десктопного GUI: навигация, формы, действия пользователя, периодическое обновление интерфейса.

Каждый класс создаётся в Class Browser с нуля. После каждого блока кода — разбор: что делает строка, зачем нужен приём, куда смотреть в документации.

База и смежные темы

Для кого материал

Ключевые понятия

ТерминЧто этоГде встретится
MorphicГрафическая система Pharo — UI из морфов, объектов, которые рисуют себя и дочерние виджетыСправочник §12, Morphic
МорфБазовый визуальный объект (Morph, BorderedMorph, StringMorph…)Шаги 3–8 ниже
Model–View–Controller (MVC)Модель хранит данные; представление рисует; контроллер связывает ввод с модельюООП, десктопные приложения
SteppingMorphic периодически шлёт морфу step — таймер без отдельного потокаШаг 8; сравните SmallShooter (~60 FPS)
ПротоколГруппа методов в браузере (initialization, actions…) — организация кодаРекомендации
ImageЖивой снимок системы — классы и объекты сохраняются с IDEО языке

Архитектура проекта

КлассРольНаследник
DesktopNoteStoreСписок заметок — добавление, удаление, очисткаObject
DesktopCalculatorЛогика калькулятора — цифры, операции, экранObject
DesktopNoteRowMorphОдна строка списка с кнопкой удаленияBorderedMorph
DesktopAppMorphГлавное окно — навигация, панели, тема, статусMorph

Как проходить практикум

  1. Создайте категорию классов SmallDesktop в System Browser.
  2. Идите по шагам 1–8 — после каждого Accept и блок "Самопроверка".
  3. На шаге 8 выполните DesktopAppMorph open.
  4. Сохраните image (File → Save).
Совет по темпу

Не перескакивайте шаги 1–2 ради "скорее увидеть окно". Модели без Morphic — основа отладки: ошибку в калькуляторе или списке заметок можно воспроизвести в Playground за секунды.


Что получится

РазделПоведение
ЗаметкиВвод текста, "Добавить", "Очистить всё", удаление строки кнопкой ×
КалькуляторЦифры, + − × ÷, десятичная точка, смена знака, C и =
О программеСтатический текст о проекте
НастройкиКнопки "Светлая тема" / "Тёмная тема"
СтатусНазвание раздела, число заметок, часы (обновление раз в секунду)

Четыре класса в категории SmallDesktop:

КлассРоль
DesktopNoteStoreМодель — список заметок
DesktopCalculatorМодель — логика калькулятора
DesktopNoteRowMorphПредставление — одна строка списка
DesktopAppMorphКонтроллер + корневое окно

Требования

  • Pharo 10 или новее (рекомендуется 11/12).
  • Пройдена первая программа — Playground, Class Browser, Accept (Ctrl+S).
  • Желательно пройти крестики-нолики — там те же приёмы Morphic на меньшем объёме.

MVC в этом проекте

В Smalltalk-80 впервые оформили Model–View–Controller. В SmallDesktop роли распределены так:

РольКлассыОтветственность
ModelDesktopNoteStore, DesktopCalculatorДанные и правила; не знают про экран
ViewDesktopNoteRowMorph, морфы внутри DesktopAppMorphРисуют текст, кнопки, рамки
ControllerМетоды DesktopAppMorph (addNote, calcButtonPressed:, showSection:)Переводят клики в сообщения модели и обновляют view

Правило практикума — сначала модель, потом UI. Так же устроен практикум "крестики-нолики": TTTGame не импортирует Morphic.


Шаг 1 — хранилище заметок DesktopNoteStore

Цель шага — описать список заметок как обычный объект Smalltalk, который можно создать и проверить в Playground без единого морфа.

Зачем отдельный класс

  • UI будет меняться (другой шрифт, прокрутка), а правила списка — "не добавлять пустое", "удалить по индексу" — останутся здесь.
  • OrderedCollection сохраняет порядок добавления — для заметок это естественнее, чем Set (типы данных).

1.1. Объявление класса

В Class BrowserNew class, шаблон замените на:

Object subclass: #DesktopNoteStore
instanceVariableNames: 'notes'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallDesktop'!

Нажмите Accept.

Разбор объявления

  • #DesktopNoteStoreглобальный символ имени класса; решётка в Smalltalk означает литерал Symbol (синтаксис).
  • instanceVariableNames: 'notes' — у каждого экземпляра свой список; переменные не видны снаружи без геттеров.
  • category: 'SmallDesktop'! — папка в браузере; восклицательный знак завершает объявление класса.

1.2. Инициализация

Протокол initialization, метод initialize:

initialize
super initialize.
notes := OrderedCollection new

Разбор

  • super initialize — базовая инициализация Object; в Pharo её вызывают явно (ООП).
  • OrderedCollection new — пустая упорядоченная коллекция; методы add:, removeAt:, size описаны в справочнике.

1.3. Доступ

Протокол accessing:

allNotes
^ notes copy

count
^ notes size

Разбор

  • ^ notes copyзащитная копия: вызывающий код получает новую коллекцию и не может испортить внутреннее состояние через add: или removeAll.
  • count — короткий геттер для строки статуса на шаге 8.

1.4. Действия

Протокол actions:

addNote: aString
| trimmed |
trimmed := aString withBlanksTrimmed.
trimmed isEmpty ifTrue: [ ^ false ].
notes add: trimmed.
^ true

clearAll
notes removeAll

removeAt: anIndex
notes removeAt: anIndex ifAbsent: []

Разбор

  • | trimmed |локальная переменная метода (объявляется в начале).
  • withBlanksTrimmed — убирает пробелы и переводы строк по краям; " " не станет заметкой.
  • ifTrue: [ ^ false ]ранний выход с результатом false; UI поймёт, что добавлять нечего.
  • removeAt: anIndex ifAbsent: [] — индексы с 1 (массивы и коллекции); при ошибочном индексе — тихий no-op.

1.5. Самопроверка

| store |
store := DesktopNoteStore new.
store addNote: ' Первая '.
store addNote: ''.
store count. " 1 "
store allNotes. " an OrderedCollection('Первая') "
store removeAt: 1.
store count. " 0 "

Ожидаемое поведение:

  • после двух addNote: в списке одна строка "Первая";
  • allNotes — копия, не тот же объект, что внутри store;
  • после removeAt: 1 счётчик снова 0.

Шаг 2 — модель калькулятора DesktopCalculator

Цель шага — вынести арифметику в модель. Кнопки на шаге 6 будут только переводить нажатия в сообщения digit:, operator:, equals.

Двухрегистровая схема

Калькулятор хранит:

ПолеНазначение
displayСтрока на "экране"
pendingValueПервый операнд, уже подтверждённый
pendingOperatorСимвол #+, #-, #* или #/
freshEntryСледующая цифра заменит экран, а не допишется

Это упрощённый калькулятор без приоритета умножения2 + 3 × 4 считается слева направо, как в учебных примерах, а не как в инженерном калькуляторе.

2.1. Объявление класса

Object subclass: #DesktopCalculator
instanceVariableNames: 'display pendingOperator pendingValue freshEntry'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallDesktop'!

2.2. Инициализация и сброс

Протокол initialization:

initialize
super initialize.
self clear

Протокол actions, метод clear:

clear
display := '0'.
pendingOperator := nil.
pendingValue := nil.
freshEntry := true

Разбор

  • nil у оператора — "операция ещё не выбрана"; на equals в таком состоянии ничего не делаем.
  • freshEntry := true после сброса — первая цифра заменит "0".

2.3. Экран и цифры

Протокол accessing:

displayText
^ display

Протокол actions:

digit: aCharacter
| digit |
digit := aCharacter asString.
freshEntry ifTrue: [
display := digit = '0' ifTrue: [ '0' ] ifFalse: [ digit ].
freshEntry := false ]
ifFalse: [
display := display = '0'
ifTrue: [ digit ]
ifFalse: [ display, digit ] ]

decimalPoint
freshEntry ifTrue: [
display := '0.'.
freshEntry := false ]
ifFalse: [
(display includes: $.) ifFalse: [
display := display, '.' ] ]

toggleSign
display := (display asNumber negated) asString

Разбор

  • $5символ цифры; asString даёт "5" для конкатенации.
  • display, digitконкатенация строк (сообщение , у String).
  • includes: $. — в строке уже есть точка; вторую не добавляем.
  • asNumber negated — быстрый способ сменить знак; для учебного калькулятора достаточно.

2.4. Операции и равно

operator: aSymbol
pendingOperator ifNotNil: [ self computePending ].
pendingValue := display asNumber.
pendingOperator := aSymbol.
freshEntry := true

equals
pendingOperator ifNil: [ ^ self ].
self computePending.
pendingOperator := nil.
pendingValue := nil.
freshEntry := true

Протокол private, метод computePending:

computePending
| left right result |
left := pendingValue.
right := display asNumber.
result := pendingOperator = #+
ifTrue: [ left + right ]
ifFalse: [
pendingOperator = #-
ifTrue: [ left - right ]
ifFalse: [
pendingOperator = #*
ifTrue: [ left * right ]
ifFalse: [
right ~= 0
ifTrue: [ left / right ]
ifFalse: [ left ] ] ] ].
display := result printString.
pendingValue := result

Разбор

  • Вложенные ifTrue:ifFalse: — типичный стиль Smalltalk вместо switch (синтаксис).
  • #+, #-символы; UI передаёт их из подписей кнопок на шаге 6.
  • right ~= 0 — деление на ноль не ломает модель; экран остаётся на left (упрощение для практикума).
  • printString — число снова становится строкой для StringMorph.

2.5. Самопроверка

| c |
c := DesktopCalculator new.
c digit: $2.
c digit: $5.
c operator: #+.
c digit: $3.
c equals.
c displayText. " '28' "

Цепочка 25 + 3 — проверка freshEntry и computePending без UI.


Шаг 3 — строка списка DesktopNoteRowMorph

Цель шага — вынести одну строку списка в отдельный морф. Главное окно не будет рисовать текст и кнопку удаления вручную для каждой заметки.

Morphic-иерархия

  • BorderedMorph — прямоугольник с рамкой (Morphic §12).
  • Внутри — StringMorph (текст) и SimpleButtonMorph (кнопка ×).
  • Родитель (DesktopAppMorph) получит сообщение deleteNoteAt:делегирование, как cellClicked: в крестиках-ноликах.

3.1. Объявление класса

BorderedMorph subclass: #DesktopNoteRowMorph
instanceVariableNames: 'index appMorph labelMorph deleteButton'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallDesktop'!

3.2. Сборка строки

Протокол initialization:

initializeApp: anAppMorph at: anIndex text: aString
appMorph := anAppMorph.
index := anIndex.
self color: Color white.
self borderWidth: 1.
self borderColor: (Color gray alpha: 0.4).
labelMorph := StringMorph contents: aString font: (TextStyle default fontOfSize: 14).
labelMorph color: Color black.
self addMorph: labelMorph.
labelMorph position: 8 @ 6.
deleteButton := SimpleButtonMorph new.
deleteButton label: '×'; font: (TextStyle default fontOfSize: 14).
deleteButton target: self; actionSelector: #deleteClicked.
self addMorph: deleteButton.
deleteButton position: 330 @ 2

Разбор

  • initializeApp:at:text:фабричная инициализация: после new сразу настраиваем морф; возвращать self не обязательно, если вызываете только ради побочного эффекта.
  • 8 @ 6 — объект Point; @ — сообщение у числа для координат внутри родителя.
  • target:actionSelector: — по клику кнопка шлёт #deleteClicked этому морфу, не окну напрямую (сравните с newGameButton в крестиках).
  • alpha: 0.4 — полупрозрачная рамка.

3.3. Удаление

Протокол actions:

deleteClicked
appMorph deleteNoteAt: index

Строка не знает про DesktopNoteStore — только индекс и ссылка на главное окно. Так проще менять хранилище или добавлять подтверждение удаления в одном месте.


Шаг 4 — каркас DesktopAppMorph

Цель шага — корневой морф, боковая навигация и переключение видимости панелей. Содержимое панелей заполните на шагах 5–7.

Компоновка окна

┌─────────────────────────────────────────────┐
│ SmallDesktop │ [активная панель 430×390] │
│ [Заметки] │ │
│ [Калькулятор] │ │
│ [О программе] │ │
│ [Настройки] │ │
│ │ Раздел: … │ Заметок: … ⏰ │
└─────────────────────────────────────────────┘
~160 px sidebar контент + status bar

4.1. Объявление класса

Morph subclass: #DesktopAppMorph
instanceVariableNames: 'noteStore calculator darkTheme currentSection sidebarButtons notesPanel calculatorPanel aboutPanel settingsPanel noteInputModel noteInputMorph noteListMorph statusMorph clockMorph accentColor'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallDesktop'!

Много инстанс-переменных — нормально для корневого Morphic-окна; альтернатива — вынести каждую панель в отдельный подкласс (усложнение на потом).

4.2. Точка входа

Протокол instance creation (метод класса):

open
^ self new openInWorld

На шаге 8 openInWorld экземпляра откроет окно с заголовком; пока достаточно для промежуточной отладки.

4.3. Инициализация

Протокол initialization:

initialize
super initialize.
noteStore := DesktopNoteStore new.
calculator := DesktopCalculator new.
darkTheme := false.
currentSection := #notes.
accentColor := (Color r: 0.2 g: 0.45 b: 0.85).
self buildUI.
self applyTheme.
self startStepping

Разбор

  • #notesсимвол текущего раздела; сравнение через = быстро и читаемо.
  • buildUI — вынесенная сборка; initialize остаётся коротким (стиль).
  • startStepping — включает вызовы step / stepTime (шаг 8).

4.4. Сборка интерфейса

buildUI
self extent: 620 @ 460.
self buildSidebar.
self buildPanels.
self buildStatusBar.
self showSection: #notes

extent: — размер в пикселях; 620 @ 460 задаёт и ширину sidebar, и область контента.

4.5. Боковая панель

buildSidebar
| title origin labels button |
sidebarButtons := Dictionary new.
title := StringMorph contents: 'SmallDesktop' font: (TextStyle default fontOfSize: 18).
title color: Color white.
self addMorph: title.
title position: 16 @ 16.

labels := #(
(#notes 'Заметки')
(#calculator 'Калькулятор')
(#about 'О программе')
(#settings 'Настройки')
).
origin := 12 @ 56.
labels do: [ :pair |
button := SimpleButtonMorph new.
button label: pair second; font: (TextStyle default fontOfSize: 13).
button target: self; actionSelector: #sidebarClicked:.
button setProperty: #desktopSection toValue: pair first.
button extent: 136 @ 28.
button position: origin.
self addMorph: button.
sidebarButtons at: pair first put: button.
origin := origin + (0 @ 34) ]

Разбор

  • #(...)литерал массива; ( #notes 'Заметки' ) — пара "символ раздела + подпись".
  • labels do: [ :pair | ... ] — перебор массива блоком (блоки кода).
  • sidebarButtonsDictionary символ → кнопка; нужен для подсветки активного раздела в showSection:.
  • setProperty:toValue: / getProperty: — произвольные метаданные на морфе без новых подклассов.

4.6. Навигация между разделами

Протокол navigation:

sidebarClicked: aButton
| section |
section := aButton getProperty: #desktopSection.
self showSection: section

showSection: aSymbol
currentSection := aSymbol.
notesPanel visible: (aSymbol = #notes).
calculatorPanel visible: (aSymbol = #calculator).
aboutPanel visible: (aSymbol = #about).
settingsPanel visible: (aSymbol = #settings).
sidebarButtons keysAndValuesDo: [ :key :button |
button color: (key = aSymbol
ifTrue: [ accentColor ]
ifFalse: [ (darkTheme ifTrue: [ Color gray darker ] ifFalse: [ Color lightGray ]) ]) ].
self refreshStatusBar

Разбор

  • Все панели созданы один раз и лежат в одной точке (170 @ 16); visible: false прячет неактивные — проще, чем пересоздавать морфы.
  • Подсветка кнопки — смена color:; в промышленном UI использовали бы стили, здесь — наглядно.
  • refreshStatusBar — текст статуса зависит от currentSection (шаг 8).

4.7. Заготовка панелей

Пока добавьте пустые панели, чтобы buildUI компилировался:

buildPanels
notesPanel := BorderedMorph new extent: 430 @ 390.
calculatorPanel := BorderedMorph new extent: 430 @ 390.
aboutPanel := BorderedMorph new extent: 430 @ 390.
settingsPanel := BorderedMorph new extent: 430 @ 390.
{ notesPanel. calculatorPanel. aboutPanel. settingsPanel } do: [ :each |
self addMorph: each.
each position: 170 @ 16 ]

{ a. b. c }литерал массива в фигурных скобках; удобно для итерации.

4.8. Самопроверка

DesktopAppMorph open

Ожидаемое:

  • слева заголовок и четыре кнопки;
  • справа одна белая панель;
  • клик по пунктам меняет цвет активной кнопки;
  • ошибок в Transcript нет.

Шаг 5 — панель заметок

Цель шага — связать TextModel, кнопки и DesktopNoteStore; список перерисовывается целиком через refreshNotes.

Ввод текста в Morphic

КомпонентРоль
TextModelХранит строку, уведомляет подписчиков об изменениях
PluggableTextMorphВиджет однострочного/многострочного ввода, привязанный к модели

Такая связка — классический Smalltalk-паттерн pluggable UI (Morphic §12).

5.1. Построение панели

Протокол initialization:

buildNotesPanel
| panel title inputRow addButton clearButton |
panel := BorderedMorph new.
panel color: Color white.
panel borderWidth: 1.
panel borderColor: (Color gray alpha: 0.35).
panel extent: 430 @ 390.

title := StringMorph contents: 'Заметки' font: (TextStyle default fontOfSize: 20).
title color: Color black.
panel addMorph: title.
title position: 16 @ 12.

noteInputModel := TextModel new.
noteInputMorph := PluggableTextMorph on: noteInputModel textColor: Color black.
noteInputMorph extent: 280 @ 24.
panel addMorph: noteInputMorph.
noteInputMorph position: 16 @ 48.

addButton := SimpleButtonMorph new.
addButton label: 'Добавить'; font: (TextStyle default fontOfSize: 13).
addButton target: self; actionSelector: #addNote.
panel addMorph: addButton.
addButton position: 306 @ 46.

clearButton := SimpleButtonMorph new.
clearButton label: 'Очистить всё'; font: (TextStyle default fontOfSize: 13).
clearButton target: self; actionSelector: #clearNotes.
panel addMorph: clearButton.
clearButton position: 16 @ 82.

noteListMorph := Morph new.
noteListMorph color: Color transparent.
noteListMorph extent: 398 @ 280.
panel addMorph: noteListMorph.
noteListMorph position: 16 @ 118.
^ panel

Разбор

  • noteInputModel contents — чтение текста при addNote (шаг 5.2).
  • noteListMorphконтейнер без рамки; дочерние DesktopNoteRowMorph позиционируются вручную (origin + (0 @ 32)).
  • Метод возвращает panel; в buildPanels пишите notesPanel := self buildNotesPanel.

5.2. Действия с заметками

Протокол notes:

addNote
noteStore addNote: noteInputModel contents ifTrue: [
noteInputModel contents: ''.
self refreshNotes ]

clearNotes
noteStore clearAll.
self refreshNotes

deleteNoteAt: anIndex
noteStore removeAt: anIndex.
self refreshNotes

refreshNotes
| origin index |
noteListMorph removeAllMorphs.
origin := 0 @ 0.
index := 1.
noteStore allNotes do: [ :each |
| row |
row := DesktopNoteRowMorph new initializeApp: self at: index text: each.
row extent: 370 @ 28.
row position: origin.
noteListMorph addMorph: row.
origin := origin + (0 @ 32).
index := index + 1 ].
self refreshStatusBar

Разбор

  • ifTrue: после addNote: — пустая строка не очищает поле ввода.
  • removeAllMorphs + полное пересоздание строк — простая стратегия; индексы в кнопках удаления всегда актуальны.
  • После изменения списка обновляем статус ("Заметок: N").

5.3. Самопроверка

  • Добавьте две заметки — обе видны, счётчик в статусе растёт.
  • Удалите одну кнопкой × — индексы не "ломаются".
  • "Очистить всё" — пустой список, счётчик 0.

Шаг 6 — панель калькулятора

Цель шага — сетка кнопок и тонкий адаптер между UI и DesktopCalculator.

Паттерн "метка на морфе"

Экран калькулятора помечен свойством #calcDisplay. При обновлении не храним отдельную ссылку в инстанс-переменной — ищем среди submorphs:

  • меньше полей в DesktopAppMorph;
  • тот же приём, что #desktopSection на кнопках навигации.

6.1. Построение панели

buildCalculatorPanel
| panel title displayMorph rows origin rowLabels button |
panel := BorderedMorph new.
panel color: Color white.
panel borderWidth: 1.
panel borderColor: (Color gray alpha: 0.35).
panel extent: 430 @ 390.

title := StringMorph contents: 'Калькулятор' font: (TextStyle default fontOfSize: 20).
title color: Color black.
panel addMorph: title.
title position: 16 @ 12.

displayMorph := StringMorph contents: calculator displayText font: (TextStyle default fontOfSize: 24).
displayMorph color: Color black.
displayMorph setProperty: #calcDisplay toValue: true.
panel addMorph: displayMorph.
displayMorph extent: 280 @ 28.
displayMorph position: 16 @ 48.

rows := #(
#('7' '8' '9' '/')
#('4' '5' '6' '*')
#('1' '2' '3' '-')
#('0' '.' '±' '+')
#('C' '=')
).
origin := 16 @ 92.
rows do: [ :rowLabels |
origin := 16 @ origin y.
rowLabels do: [ :label |
button := SimpleButtonMorph new.
button label: label; font: (TextStyle default fontOfSize: 14).
button extent: 64 @ 36.
button position: origin.
button target: self; actionSelector: #calcButtonPressed:.
button setProperty: #calcLabel toValue: label.
panel addMorph: button.
origin := origin + (72 @ 0) ].
origin := origin + (0 @ 44) ].
^ panel

Разбор

  • Вложенный do: строит сетку — outer по строкам, inner по кнопкам.
  • origin := 16 @ origin y — в начале каждой строки X сбрасывается в 16.
  • Подпись '±' обрабатывается отдельно в calcButtonPressed:.

6.2. Обработка кнопок

Протокол calculator:

calcButtonPressed: aButton
| label |
label := aButton getProperty: #calcLabel.
(label size = 1 and: [ label first isDigit ]) ifTrue: [
calculator digit: label first ].
label = '.' ifTrue: [ calculator decimalPoint ].
label = '±' ifTrue: [ calculator toggleSign ].
label = 'C' ifTrue: [ calculator clear ].
label = '=' ifTrue: [ calculator equals ].
label = '+' ifTrue: [ calculator operator: #+ ].
label = '-' ifTrue: [ calculator operator: #- ].
label = '*' ifTrue: [ calculator operator: #* ].
label = '/' ifTrue: [ calculator operator: #/ ].
self refreshCalculatorDisplay

refreshCalculatorDisplay
calculatorPanel submorphsDo: [ :each |
(each hasProperty: #calcDisplay) ifTrue: [
each contents: calculator displayText ] ]

Разбор

  • UI не считает — только переводит label в сообщения модели (MVC).
  • label first isDigit — отличим "7" от "+" без длинного case.
  • submorphsDo: — обход прямых детей панели.

6.3. Самопроверка

  • 12 + 3 = → экран 15.
  • C → снова 0.
  • Цепочка с - и ± — знак меняется без сбоя.

Шаг 7 — информация, настройки и тема

Цель шага — статический экран, переключатели темы и централизованная перекраска виджетов.

7.1. Панель "О программе"

buildAboutPanel
| panel title text |
panel := BorderedMorph new.
panel color: Color white.
panel borderWidth: 1.
panel borderColor: (Color gray alpha: 0.35).
panel extent: 430 @ 390.

title := StringMorph contents: 'О программе' font: (TextStyle default fontOfSize: 20).
title color: Color black.
panel addMorph: title.
title position: 16 @ 12.

text := StringMorph contents:
'SmallDesktop — демонстрационное настольное приложение на Pharo Morphic.' , String cr ,
String cr ,
'В приложении показаны типичные элементы интерфейса:' , String cr ,
'• боковая панель навигации' , String cr ,
'• поле ввода текста и список заметок' , String cr ,
'• кнопки действий' , String cr ,
'• простой калькулятор' , String cr ,
'• переключение светлой и тёмной темы' , String cr ,
'• строка статуса с часами' font: (TextStyle default fontOfSize: 14).
text color: Color darkGray.
panel addMorph: text.
text position: 16 @ 52.
^ panel

String cr — перевод строки; многострочный текст в одном StringMorph без TextMorph.

7.2. Панель "Настройки"

buildSettingsPanel
| panel title lightButton darkButton hint |
panel := BorderedMorph new.
panel color: Color white.
panel borderWidth: 1.
panel borderColor: (Color gray alpha: 0.35).
panel extent: 430 @ 390.

title := StringMorph contents: 'Настройки' font: (TextStyle default fontOfSize: 20).
title color: Color black.
panel addMorph: title.
title position: 16 @ 12.

lightButton := SimpleButtonMorph new.
lightButton label: 'Светлая тема'; font: (TextStyle default fontOfSize: 13).
lightButton target: self; actionSelector: #useLightTheme.
panel addMorph: lightButton.
lightButton position: 16 @ 56.

darkButton := SimpleButtonMorph new.
darkButton label: 'Тёмная тема'; font: (TextStyle default fontOfSize: 13).
darkButton target: self; actionSelector: #useDarkTheme.
panel addMorph: darkButton.
darkButton position: 140 @ 56.

hint := StringMorph contents: 'Тема меняет цвет фона окна и боковой панели.' font: (TextStyle default fontOfSize: 13).
hint color: Color gray.
panel addMorph: hint.
hint position: 16 @ 100.
^ panel

7.3. Переключение темы

Протокол theme:

useLightTheme
darkTheme := false.
self applyTheme

useDarkTheme
darkTheme := true.
self applyTheme

applyTheme
| sidebarColor panelColor textColor |
sidebarColor := darkTheme ifTrue: [ Color r: 0.12 g: 0.14 b: 0.18 ] ifFalse: [ Color r: 0.93 g: 0.94 b: 0.96 ].
panelColor := darkTheme ifTrue: [ Color r: 0.18 g: 0.2 b: 0.24 ] ifFalse: [ Color white ].
textColor := darkTheme ifTrue: [ Color white ] ifFalse: [ Color black ].
self color: sidebarColor.
self submorphsDo: [ :m |
(m isKindOf: StringMorph and: [ m contents = 'SmallDesktop' ]) ifTrue: [
m color: textColor ] ].
{ notesPanel. calculatorPanel. aboutPanel. settingsPanel } do: [ :panel |
panel color: panelColor.
panel borderColor: (darkTheme ifTrue: [ Color gray darker ] ifFalse: [ Color gray alpha: 0.35 ]).
panel submorphsDo: [ :m |
m isKindOf: StringMorph ifTrue: [ m color: textColor ] ] ].
statusMorph color: textColor.
clockMorph color: textColor.
self showSection: currentSection

Разбор

  • Три палитры через ifTrue:ifFalse: — учебная theme switch, не полноценная система стилей.
  • Обход submorphsDo: перекрашивает прямых детей панелей; строки заметок (DesktopNoteRowMorph) — отдельные морфы; для полной тёмной темы списка позже расширьте applyTheme.
  • showSection: currentSection — заново подсветить sidebar после смены фона.

Обновите buildPanels:

buildPanels
notesPanel := self buildNotesPanel.
calculatorPanel := self buildCalculatorPanel.
aboutPanel := self buildAboutPanel.
settingsPanel := self buildSettingsPanel.
{ notesPanel. calculatorPanel. aboutPanel. settingsPanel } do: [ :each |
self addMorph: each.
each position: 170 @ 16 ]

Шаг 8 — строка статуса, stepping и запуск

Цель шага — вывести агрегированную информацию и показать stepping Morphic без отдельного таймер-потока.

Stepping в Morphic

  1. startStepping в initialize регистрирует морф в очереди.
  2. Система периодически шлёт step.
  3. stepTime возвращает интервал в миллисекундах.

Для часов достаточно 1000 ms. В SmallShooter тот же механизм, но stepTime ≈ 16 — ~60 кадров в секунду.

8.1. Строка статуса

buildStatusBar
statusMorph := StringMorph contents: '' font: (TextStyle default fontOfSize: 12).
self addMorph: statusMorph.
statusMorph position: 170 @ 418.

clockMorph := StringMorph contents: '' font: (TextStyle default fontOfSize: 12).
self addMorph: clockMorph.
clockMorph position: 520 @ 418

Протокол display:

refreshStatusBar
| sectionName |
sectionName := currentSection = #notes
ifTrue: [ 'Заметки' ]
ifFalse: [
currentSection = #calculator
ifTrue: [ 'Калькулятор' ]
ifFalse: [
currentSection = #about
ifTrue: [ 'О программе' ]
ifFalse: [ 'Настройки' ] ] ].
statusMorph contents: 'Раздел: ', sectionName, ' | Заметок: ', noteStore count asString.
clockMorph contents: Time now print24

Разбор

  • Конкатенация 'Раздел: ', sectionName, ... — несколько аргументов у , у строки.
  • Time now print24 — часы:минуты:секунды в 24-часовом формате.
  • Вызов из refreshNotes, showSection: и step держит UI согласованным с моделью.

8.2. Периодическое обновление

Протокол stepping:

step
self refreshStatusBar

stepTime
^ 1000

Каждую секунду часы тикают даже без действий пользователя — типичный status bar десктопного приложения (§112).

8.3. Окно с заголовком

Протокол opening:

openInWorld
^ self openInWindowLabeled: 'SmallDesktop — демо-приложение'

Метод класса open оставьте:

open
^ self new openInWorld

openInWorld экземпляра теперь открывает окно SystemWindow, а не рисует морф на "рабочем столе" Pharo.

8.4. Финальный запуск

  1. Все четыре класса приняты (Accept) без ошибок компиляции.
  2. В Playground:
DesktopAppMorph open
  1. Сценарии:
СценарийОжидание
"Заметки" → ввод → "Добавить"Строка в списке, поле очищается, в статусе "Заметок: 1"
Кнопка × у заметкиСтрока исчезает, счётчик уменьшается
"Калькулятор" → 7 * 8 =Экран 56
"Настройки" → "Тёмная тема"Тёмный фон панелей и sidebar
Подождать 1–2 секЧасы в правом нижнем углу обновляются
"О программе"Текст о проекте
  1. Сохраните image — File → Save.

Как устроен поток событий

Клик "Добавить"
→ DesktopAppMorph>>addNote
→ DesktopNoteStore>>addNote:
→ DesktopAppMorph>>refreshNotes
→ DesktopNoteRowMorph (новые строки)
→ refreshStatusBar

Клик кнопки калькулятора
→ DesktopAppMorph>>calcButtonPressed:
→ DesktopCalculator>>digit: / operator: / …
→ refreshCalculatorDisplay

Stepping (каждую 1 с)
→ DesktopAppMorph>>step
→ refreshStatusBar (часы, счётчик)

Итог

  • Модели не импортируют Morphic — их можно unit-тестировать (101.md).
  • DesktopAppMorphконтроллер + корневой view: один класс, но роли в голове держите раздельно.
  • Любое действие пользователя → сообщение модели → явный refresh* UI; Morphic не "сам догадается".

Сравнение с SmallShooter

Практикум SmallShooterигра с циклом ~60 FPS и клавиатурой. Архитектура параллельна:

SmallDesktopSmallShooter
DesktopNoteStore, DesktopCalculatorShooterGame
DesktopAppMorphShooterGameMorph
stepTime = 1000 (часы)stepTime = 16 (анимация)
Кнопки и поле вводаhandleKeyDown: / handleKeyUp:

После этого практикума логично перейти к SmallShooter или SmallPong — там те же stepping и ввод, но другой UX.


Частые ошибки

СимптомПричинаЧто сделать
UndefinedObject>>DNU при "Добавить"Не создан noteInputModelПроверьте buildNotesPanel и порядок buildUI
Калькулятор не реагируетПанель — заготовка без кнопокВ buildPanels вызовите buildCalculatorPanel
Панели накладываютсяНе вызывается showSection:Проверьте buildUI и visible:
Часы стоятНет startStepping или stepДобавьте в initialize и протокол stepping
Тема не меняет текстНе обновлены вложенные морфыРасширьте applyTheme для DesktopNoteRowMorph
Метод не виден после правкиЗабыли AcceptCtrl+S в панели метода
Индекс при удалении "мимо"Путаница 0-based и 1-basedВ Pharo removeAt: — с 1 (33.md)

Что попробовать дальше

  1. SUnit-тест для DesktopCalculator — победа в крестиках уже показывает, как гонять модель без UI.
  2. Сохранение заметок в файл — FileStream, сериализация (типы данных).
  3. ScrollPane вокруг noteListMorph — длинный список заметок.
  4. Игровой цикл — SmallPong или SmallShooter.
  5. Вынести каждую панель в отдельный подкласс Morph — шаг к модульному UI.

Справочник Morphic — §12. Стиль и протоколы — 101.md. Следующий уровень UI — Morphic.


Содержание
Освоение главы0%