Приветствую всех!

Думаю, многие из нас хоть раз слышали о КПК на базе Palm OS. А некоторым даже довелось иметь такой в использовании.

И так уж вышло, что пальмы стали одной из самых противоречивых платформ в плане разработки под них, в чём легко можно убедиться, почитав комментарии к любой статье про программирование для этих устройств.



Итак, в сегодняшней статье поговорим о самом специфическом аспекте разработки под такие КПК. Разберёмся, что же было предметом дискуссий о целесообразности программирования для пальм, узнаем, насколько всё сложно. Традиционно будет много интересного.

Суть такова


Давным-давно я уже писал про введение в разработку под пальмы. Пост этот был весьма успешным, но там была скромно опущена одна из важнейших составляющих программирования, в общем-то, для любой платформы — хранение данных. На одних формах и тексте далеко не уехать, поэтому я решил написать продолжение данного поста, где и рассказать о том, как с этим работать.

В чём сложность?


Те, кому в своё время довелось пользоваться пальмами, знают, о чём я. Дело в том, что в этих КПК, в отличие, например, от устройств на Windows Mobile, нет никакой файловой системы, а хранилище представляет собой этакую базу данных. Таким образом, в отличие от стандартных операций над файлами необходимо следить за объёмом используемых данных, чтобы уложиться в размер одной записи (которая представляет собой вместилище для последовательности байт фиксированной длины) и при необходимости сохранить их в соседнюю ячейку, возможно, предусмотреть обмен данными с компьютером (ведь нельзя просто так взять и записать данные на карту памяти, так как во многих пальмах их попросту не было) или открыть и просмотреть запись извне. В этом плане КПК на базе Pocket PC были куда удобнее, так как предоставляли полноценную ФС со всеми её возможностями. Это и вызывало ожесточённые споры насчёт того, сложно ли писать под пальмы. Безусловно, платформа обладала и другими свойственными только ей особенностями, но работу с файлами (которых в этой ОС по сути и не было) упоминали практически все.

И вот я сам решил попробовать разобраться с использованием этой БД, на себе испытал всю боль разработчиков для пальмы, так что теперь можно наконец-то поведать об этом миру.

Обзор оборудования


Как и в прошлой статье, тестировать все приложения будем на ТСД Symbol SPT-1550.

image

Это мой любимый КПК на базе Palm OS. Не самая древняя версия системы, работа от батареек, красивый дизайн, совместимость с аксессуарами от третьей пальмы — всё это мне в нём очень нравится. На борту процессор MC68000 на 33 МГц и восемь мегабайт ОЗУ (а по совместительству и RAM-диска).

Как устроено хранилище данных в пальме


Итак, каждое приложение может создать на RAM-диске одну или несколько баз данных. Сама БД представляет собой заголовок и совокупность записей (каждая из которых тоже имеет свои атрибуты), куда можно писать какие-то данные. Максимальный размер одной записи — 64 КБ, что связано с размером блока памяти. В одной базе может храниться до 32768 записей, таким образом, БД может весить до двух ГБ. В реальности, конечно, это число гораздо меньше, так как объём RAM-диска у типичных пальм исчисляется единицами мегабайт.

Формат этих баз данных называется PDB (Palm Database), по своей структуре он очень схож с PRC (Palm Resource Code), использующемся для хранения приложений. Разница заключается лишь в том, что в PDB в записях хранятся пользовательские данные, а в PRC — исполняемый код и ресурсы.

Ресурсы


На удивление, в интернете нашлось не так уж и много информации по тому, как работать с базами данных на пальме. Конечно, есть какие-то исходники приложений, есть описание функций, но вот простого рабочего примера я так и не нашёл. Ну а раз так, самое время восполнить это упущение.
Итак, создаём новый проект в CodeWarrior. Именно его мы и возьмём за основу.



Открываем редактор ресурсов и добавляем кнопки для загрузки и выгрузки данных из БД. Также создаём несколько новых уведомлений — об отсутствии данных, об успешном или неудачном сохранении, о создании БД.



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

Rsc.h
#define NoDataAlert 1014
#define SuccessAlert 1013
#define ErrorAlert 1012
#define DbInitAlert 1011
#define GetButton 1010
#define AddButton 1009
#define MainClearTextButton 1008
#define MainDescriptionField 1007
#define MainForm 1006
#define RomIncompatibleAlert 1005
#define EditOnlyMenuBar 1003
#define MainMenuBar 1001

Rsc.rcp
// test2_Rsc.rcp
//
// PilRC-format resources for test2
//
// Generated by the CodeWarrior for Palm OS V9 application wizard

GENERATEHEADER "test2_Rsc.h"
RESETAUTOID 1000

MENU ID MainMenuBar
BEGIN
    PULLDOWN "Edit"
    BEGIN
		MENUITEM "Undo" ID 10000 "U"
		MENUITEM "Cut" ID 10001 "X"
		MENUITEM "Copy"ID 10002 "C"
		MENUITEM "Paste" ID 10003 "P"
		MENUITEM "Select All" ID 10004 "S"
		MENUITEM SEPARATOR ID 10005
		MENUITEM "Keyboard" ID 10006 "K"
		MENUITEM "Graffiti Help" ID 10007 "G"
    END


END	

MENU ID EditOnlyMenuBar
BEGIN
    PULLDOWN "Edit"
    BEGIN
		MENUITEM "Undo" ID 10000 "U"
		MENUITEM "Cut" ID 10001 "X"
		MENUITEM "Copy"ID 10002 "C"
		MENUITEM "Paste" ID 10003 "P"
		MENUITEM "Select All" ID 10004 "S"
		MENUITEM SEPARATOR ID 10005
		MENUITEM "Keyboard" ID 10006 "K"
		MENUITEM "Graffiti Help" ID 10007 "G"
    END
END

ALERT ID RomIncompatibleAlert
    DEFAULTBUTTON 0
    ERROR
BEGIN
    TITLE "System Incompatible"
    MESSAGE "System Version 3.0 or greater is required to run this application."
    BUTTONS "OK"
END

FORM ID MainForm AT (0 0 160 160)
	NOSAVEBEHIND NOFRAME
	MENUID MainMenuBar
BEGIN
	TITLE "test2"
    GRAFFITISTATEINDICATOR AT (149 148)
	FIELD ID MainDescriptionField AT (0 16 160 126)
		MULTIPLELINES
		EDITABLE
		UNDERLINED
		MAXCHARS 1024
	BUTTON "Clear Text" ID MainClearTextButton AT (1 147 AUTO 12)
	BUTTON "Add" ID AddButton  AT (73 144 32 12)
	BUTTON "Get" ID GetButton   AT (113 144 32 12)
END

ICONFAMILYEX
BEGIN
    BITMAP "icon-lg-1.bmp" BPP 1 
    BITMAP "icon-lg-2.bmp" BPP 2 
    BITMAP "icon-lg-8.bmp" BPP 8 TRANSPARENTINDEX 210 COMPRESS
    BITMAP "icon-lg-1-d144.bmp" BPP 1 DENSITY 2
    BITMAP "icon-lg-2-d144.bmp" BPP 2 DENSITY 2
    BITMAP "icon-lg-8-d144.bmp" BPP 8 TRANSPARENTINDEX 210 COMPRESS DENSITY 2
END

SMALLICONFAMILYEX
BEGIN
    BITMAP "icon-sm-1.bmp"  BPP 1
    BITMAP "icon-sm-2.bmp"  BPP 2
    BITMAP "icon-sm-8.bmp"  BPP 8 TRANSPARENTINDEX 210 COMPRESS
    BITMAP "icon-sm-1-d144.bmp" BPP 1 DENSITY 2
    BITMAP "icon-sm-2-d144.bmp" BPP 2 DENSITY 2
    BITMAP "icon-sm-8-d144.bmp" BPP 8 TRANSPARENTINDEX 210 COMPRESS DENSITY 2 
END


ALERT ID DbInitAlert 
WARNING
BEGIN
     TITLE "DB init"
     MESSAGE "DB does not exist... creating"
     BUTTONS "OK" 
END

ALERT ID ErrorAlert 
ERROR
BEGIN
     TITLE "Error"
     MESSAGE "something went wrong..."
     BUTTONS "OK" "Cancel"
END

ALERT ID SuccessAlert 
ERROR
BEGIN
     TITLE "DB write"
     MESSAGE "Succesfully saved"
     BUTTONS "OK" 
END

ALERT ID NoDataAlert 
ERROR
BEGIN
     TITLE "Empty database"
     MESSAGE "No data"
     BUTTONS "OK" 
END


Именно тут содержатся все графические элементы программы, точнее, их описание.

Создаём базу данных


Теперь очередь самого хранилища. И, понятное дело, базу данных надо для начала создать.
Для этого существует функция DmCreateDatabase, которая используется примерно так:

const char * myDBName = "myTestDB";
error = DmCreateDatabase(0, myDBName, 'TEST', 'DATA', false);
if (error) return error;

Первый параметр этой функции — номер карты памяти, где хранить эту самую базу (у КПК без съёмных носителей всегда 0), название базы (обычная строка), Creator ID (четырёхбайтный код создателя базы, теперь, когда коммерческие приложения под пальму не выпускаются, можно указать любой, главное, чтобы он не конфликтовал с каким-либо другим приложением), тип базы ('DATA') и флаг ресурсной базы (в данном случае выключенный).

После вызова этой функции будет создана пустая БД с такими атрибутами.

Но делать это надо только единожды, если попробовать создать одну и ту же БД несколько раз, произойдёт Fatal Exception. Поэтому правильным вариантом будет пытаться открыть нужную базу, а если она не открывается, то пробовать её создать.

Делается это так:

static Err checkAndCreateDatabase(void) {
	DmOpenRef myDBPointer = 0;
	Err error;
	myDBPointer = DmOpenDatabaseByTypeCreator('DATA', 'TEST', dmModeReadWrite);
	if (!myDBPointer) {
		FrmAlert(DbInitAlert);
		error = DmCreateDatabase(0, myDBName, 'TEST', 'DATA', false);
		if (error) return error;
		myDBPointer = DmOpenDatabaseByTypeCreator('DATA', 'TEST', dmModeReadWrite);
		if (!myDBPointer) {
			return DmGetLastErr();
			}
	}
	DmCloseDatabase(myDBPointer);
	return error;
}

Для начала мы просто пытаемся открыть нашу базу. Функция DmOperDatabaseByTypeCreator возвращает указатель на открытую базу. Если он нулевой, значит, база не открылась. В этом случае пытаемся её создать. Если же она не создаётся, то функция возвращает ошибку.



После того, как база была создана (или же мы просто убедились в её существовании), необходимо завершить соединение.

Записываем данные


Ну что же, время попробовать что-то записать.

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

char * tstdata = "";
FieldType * field = (FieldType*)GetObjectPtr(MainDescriptionField);
if (field)	{
	tstdata = FldGetTextPtr(field);
}

Итак, для начала надо получить указатель на это поле по его ID (указывается в конструкторе ресурсов или прописывается в *_Rsc.h-файле). Далее остаётся только получить указатель на текст.

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

static void * GetObjectPtr(UInt16 objectID)
{
	FormType * frmP;

	frmP = FrmGetActiveForm();
	return FrmGetObjectPtr(frmP, FrmGetObjectIndex(frmP, objectID));
}

Дело в том, что нельзя просто так взять и обратиться к какому-то элементу, небходимо ещё и иметь указатель на форму, на которой он расположен. Эта же функция получает нужный указатель просто по ID объекта.

С данными разобрались. Теперь очередь сохранения.

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

Итак, для начала необходимо создать новую запись. Делается это функцией DmNewRecord:

UInt16 recIndex = dmMaxRecordIndex;
MemHandle recH;
recH = DmNewRecord(myDBPointer, &recIndex, StrLen(data));

Первый параметр этой функции — указатель на открытую базу данных, второй — индекс (номер этой записи в базе, лежащий, как нетрудно догадаться, в диапазоне от 0 до 32767), третий — размер этой самой записи. dmMaxRecordIndex — особая константа, служащая для указания, что писать надо в конец базы. В данном примере записывать будем текст, поэтому в качестве размера указываем длину строки. Если же предполагается писать какие-то другие данные, то проще всего будет запихнуть их в структуру, а затем сохранять уже её.

Далее надо заблокировать этот участок памяти и записать свои данные, после чего разблокировать его обратно:

if (recH) {
	recP = MemHandleLock(recH);
	DmWrite(recP, 0, data, StrLen(data));
	error = DmReleaseRecord(myDBPointer, recIndex, true);
	MemHandleUnlock(recH);
	}

Так как это единственная запись в базе, сохранять будем по нулевому индексу.



Нужно обязательно проверять, действительно ли запись открылась, в противном случае попытка что-либо туда записать приведёт к появлению Fatal Exception.

Но, разумеется, если запись уже есть, то создавать её не надо, так что нужно добавить проверку и тут:

if(DmNumRecords(myDBPointer) == 0) {
	recH = DmNewRecord(myDBPointer, &recIndex, StrLen(data));
	if (recH) {
			recP = MemHandleLock(recH);
			DmWrite(recP, 0, data, StrLen(data));
			error = DmReleaseRecord(myDBPointer, recIndex, true);
			MemHandleUnlock(recH);
		}
	}
	else {
		UInt16 newSize = StrLen(data);
		recH = DmResizeRecord(myDBPointer, 0, newSize);
		if (recH) {
			recP = MemHandleLock(recH);
			DmWrite(recP, 0, data, StrLen(data));
			DmReleaseRecord(myDBPointer, 0, true);
			MemHandleUnlock(recH);
			}
	}

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

Вся функция для записи в итоге получилась вот такая:

static Err putIntoDatabase(char * data) {
	DmOpenRef myDBPointer = 0;
	Err error = errNone;
	UInt16 recIndex = dmMaxRecordIndex;
	MemHandle recH;
	MemPtr recP;
	
	myDBPointer = DmOpenDatabaseByTypeCreator('DATA', 'TEST', dmModeReadWrite);
	if(DmNumRecords(myDBPointer) == 0) {
	recH = DmNewRecord(myDBPointer, &recIndex, StrLen(data));
	if (recH) {
			recP = MemHandleLock(recH);
			DmWrite(recP, 0, data, StrLen(data));
			error = DmReleaseRecord(myDBPointer, recIndex, true);
			MemHandleUnlock(recH);
		}
	}
	else {
		UInt16 newSize = StrLen(data);
		recH = DmResizeRecord(myDBPointer, 0, newSize);
		if (recH) {
			recP = MemHandleLock(recH);
			DmWrite(recP, 0, data, StrLen(data));
			DmReleaseRecord(myDBPointer, 0, true);
			MemHandleUnlock(recH);
			}
	}
	error = DmCloseDatabase(myDBPointer);
	return error;
}

А вот так она вызывается в обработчике событий от главной формы:

if (eventP->data.ctlSelect.controlID == AddButton) {
	FieldType * field = (FieldType*)GetObjectPtr(MainDescriptionField);
	if (field)
	{
	tstdata = FldGetTextPtr(field);
	error = putIntoDatabase(tstdata);
	if(error != errNone) {
	FrmAlert(ErrorAlert);
	FrmCloseAllForms();
		}
	FrmAlert(SuccessAlert);
	}
}

Тут мы отправляем полученный текст для записи в базу и выдаём соответствующий alert при успешном сохранении.



В работе это выглядит примерно так.

Чтение из базы


С записью разобрались. Теперь пробуем прочитать наши данные.



Для начала убедимся, что они там вообще есть:

 if(DmNumRecords(myDBPointer) == 0) {
 	FrmAlert(NoDataAlert);
	DmCloseDatabase(myDBPointer);
 	return errNone;
 	}

Вот тут стоит упомянуть о том, как устроена каждая запись. Помимо самих данных она содержит в себе заголовок из четырёх байт, три байта из которых — ID записи (это не индекс), а ещё один — атрибуты. Четыре бита в нём — номер категории, который тоже можно задавать, а ещё четыре — статусные биты:

  1. Secret bit — защищённая запись. Если этот бит установлен, запись не показывается до ввода пароля пользователя.
  2. Busy bit — запись занята. Устанавливается при обращении к записи и сбрасывается при вызове функции DmReleaseRecord. Если этот бит установлен, другие функции не могут получить доступ к записи.
  3. Dirty bit — признак изменённой записи. Устанавливается или обнуляется в третьем аргументе функции DmReleaseRecord.
  4. Delete bit — признак удалённой записи. Само содержимое при этом сохраняется и живёт вплоть до следующей синхронизации с компьютером. При этом функция DmDeleteRecord лишь взводит этот бит, а DmRemoveRecord сносит всю запись.

Так вот, для обращения к записи существуют сразу две функции — DmGetRecord и DmQueryRecord. Разница между ними в том, первая может использоваться как для чтения, так и для записи, а вторая не устанавливает busy bit (но при этом запись открывается только для чтения). Её и будем использовать:

recH = DmQueryRecord(myDBPointer, 0);
if (recH) {
 	recP = MemHandleLock(recH);
 	updateText(recP);
     	MemHandleUnlock(recH);
}

Итак, после чтения записи мы получаем указатель на то, что содержится в ней. Далее надо заблокировать эту область памяти, обновить наш текст и разблокировать её обратно.

Таким образом, всё чтение из памяти выглядит вот так:

static Err getFromDatabase() {
	Err error = errNone;
	DmOpenRef myDBPointer = 0;
 	MemHandle recH;
 	char * recP;
 	myDBPointer = DmOpenDatabaseByTypeCreator('DATA', 'TEST', dmModeReadWrite);
 	if(DmNumRecords(myDBPointer) == 0) {
 	FrmAlert(NoDataAlert);
	DmCloseDatabase(myDBPointer);
 	return errNone;
 	}
 	recH = DmQueryRecord(myDBPointer, 0);
 	if (recH) {
 		   	recP = MemHandleLock(recH);
 		   	updateText(recP);
     		MemHandleUnlock(recH);
     		}
   	error = DmCloseDatabase(myDBPointer);
 	return error;
}

Теперь очередь самой формы. С выводом в неё текста всё очень просто:

static void updateText(char * data) {
	FieldType * field = (FieldType*)GetObjectPtr(MainDescriptionField);
	if (field)
	{
		FldDelete(field, 0, 0xFFFF);					
		FldDrawField(field);
		FldInsert(field, data, StrLen(data));
		}
}

Для начала получаем указатель на форму, затем очищаем её и выводим новый текст.



Вообще, перед началом работы следует убедиться, что база действительно открылась, так как попытка обращения к несуществующей БД вызовет критическую ошибку. Впрочем, в данном случае отсутствие такой проверки не слишком критично, так как наличие рабочей базы проверяется при запуске, а все функции перед выходом завершают подключение.

Тесты на железе


Ну что же, время протестировать эту прогу на реальном КПК. Тут уже стоят некоторые приложения, как раз можно убедиться, что их базы не конфликтуют с нашей.



И оно даже успешно работает! Если напечатать что-то в текстовом поле и попробовать сохранить, то всё проходит успешно, приложение не вылетает и не отправляет КПК в перезагрузку.

Редактирование БД на ПК


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



Вот одна из таких программ, PDB Editor. По сути в ней можно делать всё то же самое, что выполнялось нами на КПК.

Вот как-то так


Как можно видеть, работа с данными в таких КПК действительно куда сложнее таковой на других платформах. И нет, это особенность именно пальмы, на других платформах всё могло быть совершенно иначе. Вот, к примеру, чтение из файла на Psion EPOC16 (он, к слову, старше Palm OS лет так на семь) которое выполняется примерно так:

INT ret;
TEXT some_byte;
ret=p_open(&f, FileName, P_FSTREAM_TEXT|P_FSHARE|P_FRANDOM);
ret = p_read(f, &some_byte, sizeof(TEXT));

На пальме же необходимо создавать базу данных и запись в ней вообще для любого хранения, даже если необходимо записать какое-то единственное значение типа максимального числа очков в игре. И если все эти действия всё равно кажутся простыми, то на более серьёзных проектах, где писать надо много и часто, работа с БД с учётом её ограничений становится уже куда сложнее.

Такие дела.

Ссылки






Возможно, захочется почитать и это:


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


  1. AndreyDmitriev
    05.09.2023 10:03
    +3

    У меня был m500, потом Tungsten T и Tungsten T3 со слайдером. Сколько я на них книжек прочитал - не счесть. А на T3 работала навигашка TomTom c внешним BT GPS приёмником. Ещё был специальный внешний аккумулятор, а также WiFi для SD слота и складная клавиатура, на которой супруга практически написала кандидатскую в библиотеке (куда было нельзя проносить ноутбуки).

    Программировать я конечно, пробовал, но вот с CodeWarrior что-то не срослось, мне не удалось разобраться с зависимостями и довести проект до компилябельного состояния. Однако у меня получилось слепить пару приложений на такой эзотерической штуке как LabVIEW (в то время был бум этих гаджетов и NI выкатила тулкит, причём кросплатформенный, который впрочем довольно бысто закрыла). Вот осталась древняя инструкция - https://download.ni.com/support/manuals/371296a.pdf. Приложения получались довольно "увесистые" под полмегабайта, и GUI там был так себе, но было прикольно. Писать код было вообще не нужно - всё мышкой программировалось.


    1. MaFrance351 Автор
      05.09.2023 10:03
      +1

      Интересно. До этого думал, что кроме Си можно было писать только на Java (если на КПК установлена Java-машина) или на Basic (NSBasic IDE). А тут вот как.
      Хотя потребление ресурсов, судя по вашему описанию, оставляет желать лучшего..


  1. dlinyj
    05.09.2023 10:03
    +1

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


  1. maxwolf
    05.09.2023 10:03
    +3

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


    Да, писать в этой парадигме софт (и, особенно, портировать имеющийся, рассчитанный на работу с файлами и файловой системой) — отдельное приключение, но она даёт и уникальные преимущества: общие механизмы работы с разнородными данными (например, возможность поиска по данным всех установленных приложений), "бесшовное" взаимодействие приложений (возможность создавать, использовать и хранить ссылки вида база+запись, и автоматически вызывать соответствующее приложение из другого), единая системная процедура синхронизации, архивирования и миграции данных для всех (вообще всех на устройстве!) приложений...


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


    1. MaFrance351 Автор
      05.09.2023 10:03

      Да, писать в этой парадигме софт (и, особенно, портировать имеющийся, рассчитанный на работу с файлами и файловой системой) — отдельное приключение

      Весело становится, когда надо хранить данных на тысячи и тысячи записей. База товаров какая-нибудь, например, как на этом ТСД. А памяти слишком мало, чтобы один элемент упихать в одну ячейку, и надо в зависимости от размера либо оставлять как есть, создавая новую ячейку, либо считывать то, что уже там лежит, добавлять новые данные и писать обратно. В то время как на Windows CE уже были и файловые системы, и полноценные СУБД (правда, найдите мне ещё виндовый КПК с таким энергопотреблением. На батарейках были разве что совсем реликтовые аппараты типа Phillips NiNo).


      единая системная процедура синхронизации, архивирования и миграции данных для всех (вообще всех на устройстве!) приложений...

      Было даже специальный Conduit SDK, позволявший написать этакие дополнения для Hotsync. Чтобы при синхронизации сразу вызывался соответствующий компонент твоего приложения (как, например, в Documents to Go).


      пользователю принадлежит только устройство и программы

      А на деле где-то наверняка сказано, что и это ему не принадлежит, а выдаётся в бессрочную аренду, так что если внезапно что-то случится по вине производителя (типа окирпичивания после обновления или банальной региональной блокировки), то проблемы будут чисто пользователя.


  1. denim
    05.09.2023 10:03
    +1

    Познавательная некрофилия


  1. Vvintage
    05.09.2023 10:03
    +2

    А ещё на PalmOS был свой прикольный рукописный ввод. Под экраном была специальная отдельная панель для этого. Для каждого символа был свой жест стилусом, и при должной сноровке получалось писать быстрее, чем "натыкивать" мелкие символы на виртуальной клавиатуре.