SmallDesktop на Morphic — практикум
О чём эта статья
Пошаговый практикум: вы соберёте настольное приложение в Pharo на Morphic — с боковой панелью, несколькими экранами, полем ввода, кнопками и строкой статуса. Это типичный каркас десктопного GUI: навигация, формы, действия пользователя, периодическое обновление интерфейса.
Каждый класс создаётся в Class Browser с нуля. После каждого блока кода — разбор: что делает строка, зачем нужен приём, куда смотреть в документации.
База и смежные темы
- Первая программа — Playground, Accept, категории классов.
- ООП-модель — инстанс-переменные, сообщения, протоколы.
- Крестики-нолики — тот же MVC на меньшем объёме.
- Типы данных —
OrderedCollection,Symbol, строки. - Morphic — §12 справочника, подробнее Morphic.
- Теория окон — Десктопные приложения.
- Pharo 10+ (рекомендуется 11/12) — см. Pharo.
- Пройдена первая программа — Playground, Class Browser, Accept (Ctrl+S).
- Желательно пройти практикум "крестики-нолики" — там те же Morphic-приёмы на меньшем объёме.
- Теория окон — Десктопные приложения, Особенности разработки.
Ключевые понятия
| Термин | Что это | Где встретится |
|---|---|---|
| Morphic | Графическая система Pharo — UI из морфов, объектов, которые рисуют себя и дочерние виджеты | Справочник §12, Morphic |
| Морф | Базовый визуальный объект (Morph, BorderedMorph, StringMorph…) | Шаги 3–8 ниже |
| Model–View–Controller (MVC) | Модель хранит данные; представление рисует; контроллер связывает ввод с моделью | ООП, десктопные приложения |
| Stepping | Morphic периодически шлёт морфу step — таймер без отдельного потока | Шаг 8; сравните SmallShooter (~60 FPS) |
| Протокол | Группа методов в браузере (initialization, actions…) — организация кода | Рекомендации |
| Image | Живой снимок системы — классы и объекты сохраняются с IDE | О языке |
Архитектура проекта
| Класс | Роль | Наследник |
|---|---|---|
DesktopNoteStore | Список заметок — добавление, удаление, очистка | Object |
DesktopCalculator | Логика калькулятора — цифры, операции, экран | Object |
DesktopNoteRowMorph | Одна строка списка с кнопкой удаления | BorderedMorph |
DesktopAppMorph | Главное окно — навигация, панели, тема, статус | Morph |
Как проходить практикум
- Создайте категорию классов
SmallDesktopв System Browser. - Идите по шагам 1–8 — после каждого Accept и блок "Самопроверка".
- На шаге 8 выполните
DesktopAppMorph open. - Сохраните 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 роли распределены так:
| Роль | Классы | Ответственность |
|---|---|---|
| Model | DesktopNoteStore, DesktopCalculator | Данные и правила; не знают про экран |
| View | DesktopNoteRowMorph, морфы внутри DesktopAppMorph | Рисуют текст, кнопки, рамки |
| Controller | Методы DesktopAppMorph (addNote, calcButtonPressed:, showSection:) | Переводят клики в сообщения модели и обновляют view |
Правило практикума — сначала модель, потом UI. Так же устроен практикум "крестики-нолики": TTTGame не импортирует Morphic.
Шаг 1 — хранилище заметок DesktopNoteStore
Цель шага — описать список заметок как обычный объект Smalltalk, который можно создать и проверить в Playground без единого морфа.
Зачем отдельный класс
- UI будет меняться (другой шрифт, прокрутка), а правила списка — "не добавлять пустое", "удалить по индексу" — останутся здесь.
OrderedCollectionсохраняет порядок добавления — для заметок это естественнее, чемSet(типы данных).
1.1. Объявление класса
В Class Browser — New 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 | ... ]— перебор массива блоком (блоки кода).sidebarButtons—Dictionaryсимвол → кнопка; нужен для подсветки активного раздела в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
startSteppingвinitializeрегистрирует морф в очереди.- Система периодически шлёт
step. 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. Финальный запуск
- Все четыре класса приняты (Accept) без ошибок компиляции.
- В Playground:
DesktopAppMorph open
- Сценарии:
| Сценарий | Ожидание |
|---|---|
| "Заметки" → ввод → "Добавить" | Строка в списке, поле очищается, в статусе "Заметок: 1" |
| Кнопка × у заметки | Строка исчезает, счётчик уменьшается |
"Калькулятор" → 7 * 8 = | Экран 56 |
| "Настройки" → "Тёмная тема" | Тёмный фон панелей и sidebar |
| Подождать 1–2 сек | Часы в правом нижнем углу обновляются |
| "О программе" | Текст о проекте |
- Сохраните 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 и клавиатурой. Архитектура параллельна:
| SmallDesktop | SmallShooter |
|---|---|
DesktopNoteStore, DesktopCalculator | ShooterGame |
DesktopAppMorph | ShooterGameMorph |
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 |
| Метод не виден после правки | Забыли Accept | Ctrl+S в панели метода |
| Индекс при удалении "мимо" | Путаница 0-based и 1-based | В Pharo removeAt: — с 1 (33.md) |
Что попробовать дальше
- SUnit-тест для
DesktopCalculator— победа в крестиках уже показывает, как гонять модель без UI. - Сохранение заметок в файл —
FileStream, сериализация (типы данных). ScrollPaneвокругnoteListMorph— длинный список заметок.- Игровой цикл — SmallPong или SmallShooter.
- Вынести каждую панель в отдельный подкласс
Morph— шаг к модульному UI.
Справочник Morphic — §12. Стиль и протоколы — 101.md. Следующий уровень UI — Morphic.