diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h index 5c654eeaa..deeae6517 100644 --- a/Modules/app-module/include/app/manager.h +++ b/Modules/app-module/include/app/manager.h @@ -140,6 +140,19 @@ error_t app_manager_get_topmost_instance_id(AppInstanceId* out_app_instance_id); */ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size); +/** + * Resolves any instance's manifest id string, not just the topmost one - e.g. for a window that + * is buried in the window manager's stack. + * @param[in] app_instance_id the instance to look up + * @param[out] buffer always NULL-terminated on return (at least "" is written when + * @a buffer_size > 0; nothing is written when @a buffer_size == 0) + * @retval ERROR_NOT_FOUND no such instance (any more), or it has no registered manifest id + * @retval ERROR_BUFFER_OVERFLOW @a buffer_size is too small to hold the id (including the NULL + * terminator) + * @retval ERROR_NONE on success + */ +error_t app_manager_get_instance_app_id(AppInstanceId app_instance_id, char* buffer, size_t buffer_size); + /** * Registers @a path as a directory to scan for app manifests - each direct subdirectory of * @a path is expected to hold a manifest.properties (see app/package_manifest.h), matching the diff --git a/Modules/app-module/source/manager.cpp b/Modules/app-module/source/manager.cpp index 646c6ffbc..43913f2d8 100644 --- a/Modules/app-module/source/manager.cpp +++ b/Modules/app-module/source/manager.cpp @@ -282,6 +282,29 @@ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size) { return ERROR_NONE; } +error_t app_manager_get_instance_app_id(AppInstanceId app_instance_id, char* buffer, size_t buffer_size) { + if (buffer_size == 0) { + return ERROR_BUFFER_OVERFLOW; + } + buffer[0] = '\0'; + + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator == ledger.instances.end() || iterator->second.manifestId.empty()) { + mutex_unlock(&ledger.mutex); + return ERROR_NOT_FOUND; + } + const std::string& manifest_id = iterator->second.manifestId; + if (manifest_id.size() >= buffer_size) { + mutex_unlock(&ledger.mutex); + return ERROR_BUFFER_OVERFLOW; + } + memcpy(buffer, manifest_id.c_str(), manifest_id.size() + 1); + mutex_unlock(&ledger.mutex); + return ERROR_NONE; +} + } // extern "C" namespace { diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp index a4aa0e8c3..e76bf5c63 100644 --- a/Modules/app-module/source/module.cpp +++ b/Modules/app-module/source/module.cpp @@ -73,6 +73,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(app_manager_for_each_package), DEFINE_MODULE_SYMBOL(app_manager_get_topmost_instance_id), DEFINE_MODULE_SYMBOL(app_manager_get_topmost_app_id), + DEFINE_MODULE_SYMBOL(app_manager_get_instance_app_id), DEFINE_MODULE_SYMBOL(app_manager_install_path_add), DEFINE_MODULE_SYMBOL(app_manager_install_path_scan), DEFINE_MODULE_SYMBOL(app_manager_install_path_uninstall), diff --git a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h index 6377bc339..6b9485e6a 100644 --- a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h +++ b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h @@ -8,6 +8,7 @@ #include #include +#include #include #ifdef __cplusplus @@ -16,6 +17,9 @@ extern "C" { typedef uint32_t WindowId; +/** A WindowId that never refers to a window: window_manager_create() returns a non-zero id. */ +#define WINDOW_ID_NONE 0 + enum WindowState { /** id is the current topmost window and has live widgets. */ WINDOW_STATE_GRANTED, @@ -133,6 +137,39 @@ void window_manager_remove(WindowId id); /** @return the current state of @a id; WINDOW_STATE_REVOKED if @a id is buried or doesn't exist. */ enum WindowState window_manager_get_state(WindowId id); +/** One entry of the window stack, see window_manager_get_windows(). */ +struct WindowInfo { + WindowId id; + AppInstanceId app_instance_id; +}; + +/** + * Copies the window stack, bottom-most first (the topmost window is the last entry). + * Only touches the module's own state (no LVGL calls), so it's safe to call from LVGL event + * callbacks. + * @param[out] out receives the entries; may be NULL to only get the count + * @param[in] offset the index of the first entry to copy (0 is the bottom-most window) + * @param[in] capacity the number of entries @a out can hold + * @return the number of windows in the stack (which may exceed offset + capacity) + */ +size_t window_manager_get_windows(struct WindowInfo* out, size_t offset, size_t capacity); + +/** + * Moves a window to the top of the stack and builds its widgets, deleting the widgets of + * whichever window was topmost. The raised app keeps running: its widgets are rebuilt through + * the @a create_widgets callback it registered, so the app's own state (in its Context) survives. + * This is what "home" (raise the launcher) and the recents switcher (raise another app) use. + * @warning Takes the window manager's lifecycle mutex, which is held while app windows are + * built. Do NOT call this from an LVGL event callback: a concurrent + * window_manager_create()/remove() holding that mutex may be waiting for the LVGL lock, which + * the callback holds. Defer it with tt::dispatchOnMainDispatcher() instead (see + * SystemGestures.cpp). + * @retval ERROR_NONE the window was raised, or already is topmost + * @retval ERROR_INVALID_STATE window_manager_start() hasn't been called + * @retval ERROR_NOT_FOUND no window with this id exists + */ +error_t window_manager_raise(WindowId id); + /** * Blocks the calling task until @a id's state changes away from WINDOW_STATE_GRANTED, or * @a timeout elapses. Returns immediately with WINDOW_STATE_REVOKED if @a id isn't currently diff --git a/Modules/lvgl-window-manager-module/source/module.cpp b/Modules/lvgl-window-manager-module/source/module.cpp index 502d92e15..14b47b315 100644 --- a/Modules/lvgl-window-manager-module/source/module.cpp +++ b/Modules/lvgl-window-manager-module/source/module.cpp @@ -11,6 +11,8 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(window_manager_create_ext), DEFINE_MODULE_SYMBOL(window_manager_remove), DEFINE_MODULE_SYMBOL(window_manager_get_state), + DEFINE_MODULE_SYMBOL(window_manager_get_windows), + DEFINE_MODULE_SYMBOL(window_manager_raise), DEFINE_MODULE_SYMBOL(window_manager_await_state_change), DEFINE_MODULE_SYMBOL(window_manager_overlay_show), DEFINE_MODULE_SYMBOL(window_manager_overlay_hide), diff --git a/Modules/lvgl-window-manager-module/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp index 814bdf022..1cda1fa79 100644 --- a/Modules/lvgl-window-manager-module/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -563,6 +563,82 @@ WindowState window_manager_get_state(WindowId id) { return is_top ? WINDOW_STATE_GRANTED : WINDOW_STATE_REVOKED; } +size_t window_manager_get_windows(WindowInfo* out, size_t offset, size_t capacity) { + auto& s = state(); + mutex_lock(&s.mutex); + const size_t count = s.windows.size(); + if (out != nullptr) { + for (size_t i = 0; i < capacity && offset + i < count; i++) { + out[i] = WindowInfo { s.windows[offset + i].id, s.windows[offset + i].app_instance_id }; + } + } + mutex_unlock(&s.mutex); + return count; +} + +error_t window_manager_raise(WindowId id) { + auto& s = state(); + + // Same serialization as create()/remove(), see lifecycle_mutex's comment. Do not call this + // from an LVGL event callback - see this function's warning in the header. + mutex_lock(&s.lifecycle_mutex); + + mutex_lock(&s.mutex); + if (!s.started) { + mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); + return ERROR_INVALID_STATE; + } + auto iterator = std::find_if(s.windows.begin(), s.windows.end(), + [id](const WindowRecord& window) { return window.id == id; }); + if (iterator == s.windows.end()) { + mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); + return ERROR_NOT_FOUND; + } + if (iterator + 1 == s.windows.end()) { + // Already topmost: its widgets are live and there's nothing to rebuild. + mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); + return ERROR_NONE; + } + + // The current topmost window is about to lose its widgets. Claim its waiter and capture what + // its destroy_widgets needs, exactly like create() does when it supersedes it. + WindowWaitSignal* waiter = claim_waiter_locked(s.windows.back()); + WindowDestroyWidgetsFn old_destroy_widgets = s.windows.back().destroy_widgets; + void* old_user_data = s.windows.back().user_data; + lv_obj_t* old_widget = s.top_widget; + s.top_widget = nullptr; + + // A buried window never has a waiting signal (it's only ever set while topmost). + const WindowRecord record = *iterator; + s.windows.erase(iterator); + s.windows.push_back(record); + lv_obj_t* content = s.content_root_widget; + mutex_unlock(&s.mutex); + + give_and_release(waiter); + + delete_widget(old_widget, old_destroy_widgets, old_user_data); + lv_obj_t* new_widget = build_window_widget(content, record.create_widgets, record.user_data); + + mutex_lock(&s.mutex); + bool still_topmost = !s.windows.empty() && s.windows.back().id == id; + if (still_topmost) { + s.top_widget = new_widget; + new_widget = nullptr; // consumed + } + mutex_unlock(&s.mutex); + + // Another window became topmost while this one was building (e.g. a concurrent create()). + // The raised record stays in the stack; its widgets are rebuilt whenever it surfaces again. + delete_widget(new_widget, record.destroy_widgets, record.user_data); + + mutex_unlock(&s.lifecycle_mutex); + return ERROR_NONE; +} + WindowState window_manager_await_state_change(WindowId id, TickType_t timeout) { auto& s = state(); diff --git a/Tactility/Kconfig b/Tactility/Kconfig index 76cd114be..730435171 100644 --- a/Tactility/Kconfig +++ b/Tactility/Kconfig @@ -92,6 +92,14 @@ menu "Tactility App" bool "Set true when a touch screen calibration is required before the device is usable" default n depends on TT_TOUCH_CALIBRATION_SUPPORTED + config TT_SYSTEM_GESTURES + bool "System gestures for touch screens" + default y + help + Edge swipes: up from the bottom edge goes home (hold it for the recents switcher), and + right from the left edge closes the topmost app. Devices without a pointer input device + ignore this at runtime, so keypad/encoder-only devices are unaffected. Disable it to + leave the code out of the firmware entirely. config TT_DISPLAY_IDLE_TICK_MS int "Display idle tick interval (ms)" default 50 diff --git a/Tactility/Private/Tactility/Backlight.h b/Tactility/Private/Tactility/Backlight.h new file mode 100644 index 000000000..7e323df28 --- /dev/null +++ b/Tactility/Private/Tactility/Backlight.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +#include + +namespace tt::backlight { + +/** + * The backlight of the device's first display, or nullptr when it has none. + * Boards not yet migrated to the kernel display driver register a placeholder display device with + * a NULL api, which has no backlight to hand out. + * @warning The caller owns the returned device and must release it with device_put(). + */ +inline Device* findDisplayBacklight() { + Device* display = nullptr; + if (device_get_first_by_type(&DISPLAY_TYPE, &display) != ERROR_NONE) { + return nullptr; + } + if (device_get_driver(display)->api == nullptr) { + device_put(display); + return nullptr; + } + Device* found = nullptr; + display_get_backlight(display, &found); + device_put(display); + return found; +} + +/** @return true when the backlight can be dimmed, rather than only switched on and off */ +inline bool isDimmable(Device* backlight) { + const int32_t min = backlight_get_min_brightness(backlight); + const int32_t max = backlight_get_max_brightness(backlight); + // A 0-1 range is an on/off switch: every slider position would map to "on" + return max > min && !(min == 0 && max == 1); +} + +/** @return the backlight's device units as a percentage of its range */ +inline int32_t toPercent(Device* backlight, int32_t brightness) { + const int32_t min = backlight_get_min_brightness(backlight); + const int32_t range = backlight_get_max_brightness(backlight) - min; + return range > 0 ? ((brightness - min) * 100 + range / 2) / range : 100; +} + +/** @return a percentage as the backlight's device units. 0% is the dimmest setting that is still on. */ +inline int32_t toBrightness(Device* backlight, int32_t percent) { + const int32_t min = backlight_get_min_brightness(backlight); + const int32_t range = backlight_get_max_brightness(backlight) - min; + const int32_t brightness = min + (percent * range + 50) / 100; + // A brightness of 0 turns the backlight off + return brightness < 1 ? 1 : brightness; +} + +} diff --git a/Tactility/Private/Tactility/app/AppIcon.h b/Tactility/Private/Tactility/app/AppIcon.h new file mode 100644 index 000000000..60fffe4f2 --- /dev/null +++ b/Tactility/Private/Tactility/app/AppIcon.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace tt::app { + +/** + * The shared icon font character for an app, as the launcher and the recents switcher show it. + * @param[in] manifest the app to look up + * @return the icon, or a generic one for apps without a specific icon + */ +const char* getAppIcon(const ::AppManifest& manifest); + +} diff --git a/Tactility/Private/Tactility/lvgl/SystemGestures.h b/Tactility/Private/Tactility/lvgl/SystemGestures.h new file mode 100644 index 000000000..6eeec19e2 --- /dev/null +++ b/Tactility/Private/Tactility/lvgl/SystemGestures.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +namespace tt::lvgl { + +/** The width of the strip along an edge in which a drag belongs to the system instead of the app. */ +constexpr int32_t SYSTEM_GESTURE_EDGE_SIZE = 20; +/** The minimum travel for a drag that starts in an edge strip to count as a swipe. */ +constexpr int32_t SYSTEM_GESTURE_SWIPE_DISTANCE = 40; + +/** The screen edges that the system takes drags from. */ +enum class SystemGestureEdge { + None, + Bottom, + Left, +}; + +/** + * Which system gesture a drag that starts at a screen edge belongs to. + * A drag that starts in a strip is the system's, whatever it is over: that's what makes the + * gestures work on top of scrollable apps. Everywhere else, apps keep their own swipes. + * @param[in] x,y where the press started, in screen coordinates + * @param[in] width,height the display size + * @return the edge that owns the drag, or SystemGestureEdge::None + */ +constexpr SystemGestureEdge systemGestureEdgeAt(int32_t x, int32_t y, int32_t width, int32_t height) { + if (y >= height - SYSTEM_GESTURE_EDGE_SIZE) { + return SystemGestureEdge::Bottom; + } + if (x < SYSTEM_GESTURE_EDGE_SIZE) { + return SystemGestureEdge::Left; + } + return SystemGestureEdge::None; +} + +/** + * @return true when the travel from the press to the release point is an upward swipe, rather + * than a mostly-sideways drag + */ +constexpr bool systemGestureIsSwipeUp(int32_t pressX, int32_t pressY, int32_t x, int32_t y) { + const int32_t dy = pressY - y; + return dy >= SYSTEM_GESTURE_SWIPE_DISTANCE && dy > (x > pressX ? x - pressX : pressX - x); +} + +/** + * @return true when the travel from the press to the release point is a rightward swipe, rather + * than a mostly-vertical drag + */ +constexpr bool systemGestureIsSwipeRight(int32_t pressX, int32_t pressY, int32_t x, int32_t y) { + const int32_t dx = x - pressX; + return dx >= SYSTEM_GESTURE_SWIPE_DISTANCE && dx > (y > pressY ? y - pressY : pressY - y); +} + +/** + * Starts listening to the pointer devices for the system gestures: swipe up from the bottom edge + * to go home (holding it opens the recents switcher), and swipe right from the left edge to close + * the topmost app. Does nothing on devices without a pointer device, so keypad/encoder-only + * devices behave as before. + * @warning Caller must hold the LVGL lock. + */ +void systemGesturesAttach(); + +/** + * Stops listening to the pointer devices. + * @warning Caller must hold the LVGL lock. + */ +void systemGesturesDetach(); + +} diff --git a/Tactility/Private/Tactility/lvgl/SystemRecents.h b/Tactility/Private/Tactility/lvgl/SystemRecents.h new file mode 100644 index 000000000..c5a105dc3 --- /dev/null +++ b/Tactility/Private/Tactility/lvgl/SystemRecents.h @@ -0,0 +1,12 @@ +#pragma once + +namespace tt::lvgl { + +/** + * Shows the recent-apps switcher: the app stack as horizontal cards, most recent first. + * Tapping a card raises that app's window (it keeps running), swiping a card up closes that app. + * @warning Caller must hold the LVGL lock. + */ +void systemRecentsShow(); + +} diff --git a/Tactility/Private/Tactility/lvgl/SystemWindow.h b/Tactility/Private/Tactility/lvgl/SystemWindow.h new file mode 100644 index 000000000..7fbb8ba93 --- /dev/null +++ b/Tactility/Private/Tactility/lvgl/SystemWindow.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include + +#include + +#include + +namespace tt::lvgl { + +/** A window id as an opaque event context, and back. Window ids never reach a pointer's size. */ +inline void* toWindowContext(WindowId window) { + return reinterpret_cast(static_cast(window)); +} + +inline WindowId fromWindowContext(const void* context) { + return static_cast(reinterpret_cast(context)); +} + +/** + * Calls @a action for every window, bottom-most first, without copying the whole stack: the stack + * is unbounded, and these run on the LVGL task. + * @param[in] action called with each window; return false to stop + */ +template +void forEachWindow(Action&& action) { + constexpr size_t CHUNK_SIZE = 8; + WindowInfo windows[CHUNK_SIZE]; + const size_t total = window_manager_get_windows(nullptr, 0, 0); + for (size_t offset = 0; offset < total; offset += CHUNK_SIZE) { + const size_t count = window_manager_get_windows(windows, offset, CHUNK_SIZE); + for (size_t index = 0; index < count && index < CHUNK_SIZE; index++) { + if (!action(windows[index])) { + return; + } + } + } +} + +/** @return the topmost window, or a window with id WINDOW_ID_NONE when no window exists */ +inline WindowInfo getTopWindow() { + WindowInfo window = { WINDOW_ID_NONE, 0 }; + const size_t total = window_manager_get_windows(nullptr, 0, 0); + if (total != 0) { + window_manager_get_windows(&window, total - 1, 1); + } + return window; +} + +/** + * Raises a window off the LVGL task. + * @warning window_manager_raise() takes the window manager's lifecycle mutex, which is held while + * windows are built; calling it from an LVGL event callback can deadlock against a concurrent + * create/remove that is waiting for the LVGL lock. Every raise goes through here for that reason. + */ +inline void raiseWindow(WindowId window) { + dispatchOnMainDispatcher([](void* context) { + window_manager_raise(fromWindowContext(context)); + }, toWindowContext(window)); +} + +} diff --git a/Tactility/Source/app/AppIcon.cpp b/Tactility/Source/app/AppIcon.cpp new file mode 100644 index 000000000..07e3caaf4 --- /dev/null +++ b/Tactility/Source/app/AppIcon.cpp @@ -0,0 +1,58 @@ +#include + +#include + +#include + +namespace tt::app { + +namespace { + +struct IconEntry { + const char* id; + const char* icon; +}; + +// The apps of the launcher's apps mode, its settings mode, and the apps the system itself shows +// (the launcher in the recents switcher) +constexpr IconEntry ICONS[] = { + {"tactility.apphub", LVGL_ICON_SHARED_DOWNLOAD}, + {"tactility.camera", LVGL_ICON_SHARED_CAMERA}, + {"tactility.chat", LVGL_ICON_SHARED_FORUM}, + {"tactility.files", LVGL_ICON_SHARED_FOLDER}, + {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, + {"tactility.launcher", LVGL_ICON_SHARED_APPS}, + {"tactility.meshchannels", LVGL_ICON_SHARED_FORUM}, + {"tactility.meshcontacts", LVGL_ICON_SHARED_FORUM}, + {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, + {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, + {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, + + {"tactility.appearance", LVGL_ICON_SHARED_PALETTE}, + {"tactility.audiosettings", LVGL_ICON_SHARED_MUSIC_NOTE}, + {"tactility.btmanage", LVGL_ICON_SHARED_BLUETOOTH}, + {"tactility.development", LVGL_ICON_SHARED_LOGO_DEV}, + {"tactility.display", LVGL_ICON_SHARED_DISPLAY_SETTINGS}, + {"tactility.grovesettings", LVGL_ICON_SHARED_CABLE}, + {"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT}, + {"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP}, + {"tactility.locationsettings", LVGL_ICON_SHARED_LOCATION_ON}, + {"tactility.meshsettings", LVGL_ICON_SHARED_HUB}, + {"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW}, + {"tactility.timedatesettings", LVGL_ICON_SHARED_CALENDAR_MONTH}, + {"tactility.touchcalibration", LVGL_ICON_SHARED_CIRCLE}, + {"tactility.trackballsettings", LVGL_ICON_SHARED_DEVICES}, + {"tactility.usbsettings", LVGL_ICON_SHARED_USB}, + {"tactility.wifimanage", LVGL_ICON_SHARED_WIFI}, +}; + +} + +const char* getAppIcon(const ::AppManifest& manifest) { + for (const auto& entry : ICONS) { + if (!strcmp(manifest.id, entry.id)) return entry.icon; + } + return LVGL_ICON_SHARED_DEPLOYED_CODE; +} + +} diff --git a/Tactility/Source/app/display/Display.cpp b/Tactility/Source/app/display/Display.cpp index 352ff7a10..8134386e0 100644 --- a/Tactility/Source/app/display/Display.cpp +++ b/Tactility/Source/app/display/Display.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #ifdef ESP_PLATFORM @@ -90,49 +91,19 @@ struct Context { }; -Device* getBacklightDevice() { - Device* display; - check(device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE); - // Boards not yet migrated to the kernel display driver register a placeholder device (so the - // devicetree node resolves) with a NULL api - nothing for display_get_backlight() to act on. - if (device_get_driver(display)->api == nullptr) { - device_put(display); - return nullptr; - } - Device* backlight = nullptr; - display_get_backlight(display, &backlight); - device_put(display); - return backlight; -} - void onBackPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); app_event_emit_close(ctx->appInstanceId); } -// The slider shows a percentage of the backlight's brightness range -int32_t brightnessToPercent(Device* backlight, int32_t brightness) { - const int32_t min = backlight_get_min_brightness(backlight); - const int32_t range = backlight_get_max_brightness(backlight) - min; - return range > 0 ? ((brightness - min) * 100 + range / 2) / range : 100; -} - -int32_t percentToBrightness(Device* backlight, int32_t percent) { - const int32_t min = backlight_get_min_brightness(backlight); - const int32_t range = backlight_get_max_brightness(backlight) - min; - const int32_t brightness = min + (percent * range + 50) / 100; - // A brightness of 0 turns the backlight off, so 0% is the dimmest setting that is still on - return brightness < 1 ? 1 : brightness; -} - void onBacklightSliderEvent(lv_event_t* event) { auto* slider_box = lv_event_get_current_target_obj(event); auto* ctx = static_cast(lv_event_get_user_data(event)); - auto* backlight = getBacklightDevice(); + auto* backlight = tt::backlight::findDisplayBacklight(); assert(backlight != nullptr); const int32_t percent = lvgl_sliderbox_get_value(slider_box); - ctx->displaySettings.backlightDuty = static_cast(percentToBrightness(backlight, percent)); + ctx->displaySettings.backlightDuty = static_cast(tt::backlight::toBrightness(backlight, percent)); ctx->displaySettingsUpdated = true; backlight_set_brightness(backlight, ctx->displaySettings.backlightDuty); device_put(backlight); @@ -340,7 +311,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* backlight = getBacklightDevice(); + auto* backlight = tt::backlight::findDisplayBacklight(); auto* toolbar = lvgl_toolbar_create(parent, "Display"); // The global toolbar nav callback only knows how to stop old-model apps. @@ -357,15 +328,14 @@ void createWidgets(lv_obj_t* parent, void* userData) { // DisplayApi has no gamma curve control yet. if (backlight != nullptr) { - bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1; - if (!is_on_off_brightness) { + if (tt::backlight::isDimmable(backlight)) { auto* brightness_label = lv_label_create(main_wrapper); lv_label_set_text(brightness_label, "Brightness"); auto* brightness_card = lvgl_card_create(main_wrapper); lv_obj_set_size(brightness_card, LV_PCT(100), LV_SIZE_CONTENT); - const int32_t percent = brightnessToPercent(backlight, ctx->displaySettings.backlightDuty); + const int32_t percent = tt::backlight::toPercent(backlight, ctx->displaySettings.backlightDuty); auto* brightness_slider = lvgl_sliderbox_create(brightness_card, 0, 100, 10, percent); lv_obj_set_width(brightness_slider, LV_PCT(100)); lvgl_sliderbox_add_value_changed_cb(brightness_slider, onBacklightSliderEvent, ctx); diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index ebd3db2a0..33b7aa152 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -35,31 +36,6 @@ void saveFavourites(void* context) { delete job; } -struct IconEntry { - const char* id; - const char* icon; -}; - -constexpr IconEntry ICONS[] = { - {"tactility.apphub", LVGL_ICON_SHARED_DOWNLOAD}, - {"tactility.camera", LVGL_ICON_SHARED_CAMERA}, - {"tactility.chat", LVGL_ICON_SHARED_FORUM}, - {"tactility.files", LVGL_ICON_SHARED_FOLDER}, - {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, - {"tactility.meshchannels", LVGL_ICON_SHARED_FORUM}, - {"tactility.meshcontacts", LVGL_ICON_SHARED_FORUM}, - {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, - {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, - {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, -}; - -const char* appIcon(const ::AppManifest* manifest) { - for (const auto& entry : ICONS) { - if (!strcmp(manifest->id, entry.id)) return entry.icon; - } - return LVGL_ICON_SHARED_DEPLOYED_CODE; -} - bool isShown(const ::AppManifest& manifest) { if (manifest.category != APP_CATEGORY_USER && manifest.category != APP_CATEGORY_SYSTEM) { return false; @@ -104,7 +80,7 @@ std::vector collectItems() { std::vector items; items.reserve(collected.size()); for (const auto& manifest : collected) { - items.push_back({ manifest.id, manifest.name, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) }); + items.push_back({ manifest.id, manifest.name, tt::app::getAppIcon(manifest), Favourites::contains(favouriteIds, manifest.id) }); } ::std::ranges::sort(items, [](const TileGridItem& a, const TileGridItem& b) { if (a.highlighted != b.highlighted) { diff --git a/Tactility/Source/app/launcher/SettingsMode.cpp b/Tactility/Source/app/launcher/SettingsMode.cpp index 35cfdc0be..d387062cf 100644 --- a/Tactility/Source/app/launcher/SettingsMode.cpp +++ b/Tactility/Source/app/launcher/SettingsMode.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -12,37 +13,6 @@ namespace tt::app::launcher { namespace { -struct IconEntry { - const char* id; - const char* icon; -}; - -constexpr IconEntry ICONS[] = { - {"tactility.appearance", LVGL_ICON_SHARED_PALETTE}, - {"tactility.audiosettings", LVGL_ICON_SHARED_MUSIC_NOTE}, - {"tactility.btmanage", LVGL_ICON_SHARED_BLUETOOTH}, - {"tactility.development", LVGL_ICON_SHARED_LOGO_DEV}, - {"tactility.display", LVGL_ICON_SHARED_DISPLAY_SETTINGS}, - {"tactility.grovesettings", LVGL_ICON_SHARED_CABLE}, - {"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT}, - {"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP}, - {"tactility.locationsettings", LVGL_ICON_SHARED_LOCATION_ON}, - {"tactility.meshsettings", LVGL_ICON_SHARED_HUB}, - {"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW}, - {"tactility.timedatesettings", LVGL_ICON_SHARED_CALENDAR_MONTH}, - {"tactility.touchcalibration", LVGL_ICON_SHARED_CIRCLE}, - {"tactility.trackballsettings", LVGL_ICON_SHARED_DEVICES}, - {"tactility.usbsettings", LVGL_ICON_SHARED_USB}, - {"tactility.wifimanage", LVGL_ICON_SHARED_WIFI}, -}; - -const char* appIcon(const ::AppManifest* manifest) { - for (const auto& entry : ICONS) { - if (!strcmp(manifest->id, entry.id)) return entry.icon; - } - return LVGL_ICON_SHARED_DEPLOYED_CODE; -} - void collectManifest(const ::AppManifest* manifest, void* context) { auto* manifests = static_cast*>(context); manifests->push_back(*manifest); @@ -58,7 +28,7 @@ std::vector collectItems() { std::vector items; for (const auto& manifest : collected) { if (manifest.category == APP_CATEGORY_SETTINGS && (manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) { - items.push_back({ manifest.id, manifest.name, appIcon(&manifest), false }); + items.push_back({ manifest.id, manifest.name, tt::app::getAppIcon(manifest), false }); } } return items; diff --git a/Tactility/Source/lvgl/Lvgl.cpp b/Tactility/Source/lvgl/Lvgl.cpp index 5b5aaabfc..f34225127 100644 --- a/Tactility/Source/lvgl/Lvgl.cpp +++ b/Tactility/Source/lvgl/Lvgl.cpp @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -60,13 +62,13 @@ namespace service { namespace lvgl { static void stopAppFromToolbar(lv_event_t*) { - // Default nav action for any toolbar that doesn't override it itself. Prefer the topmost - // new-model app if one is showing; fall back to the old system otherwise (this is what - // every not-yet-converted app's toolbar still relies on). - AppInstanceId topmost = 0; - check(app_manager_get_topmost_instance_id(&topmost) == ERROR_NONE); - - app_event_emit_close(topmost); + // Default nav action for any toolbar that doesn't override it itself. The window stack decides + // which app that is: after going home, the launcher is on top while a newer app instance is + // buried below it, so the newest instance isn't necessarily the one being shown. + const WindowInfo top = getTopWindow(); + if (top.id != WINDOW_ID_NONE) { + app_event_emit_close(top.app_instance_id); + } } // The on-screen keyboard widget itself, constructed during windowManagerScreenInit @@ -119,6 +121,9 @@ static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) { // Places the statusbar on top, or in a strip on the side systemBarsAttach(vertical_container, statusbar, app_container); + // Edge swipes: home and recents from the bottom edge, back from the left edge + systemGesturesAttach(); + return app_container; } @@ -211,6 +216,7 @@ static void onLvglStopped() { lvgl_lock(); systemBarsDetach(); + systemGesturesDetach(); lvgl_unlock(); if (softwareKeyboard.object != nullptr) { diff --git a/Tactility/Source/lvgl/SystemGestures.cpp b/Tactility/Source/lvgl/SystemGestures.cpp new file mode 100644 index 000000000..ebe70254b --- /dev/null +++ b/Tactility/Source/lvgl/SystemGestures.cpp @@ -0,0 +1,270 @@ +#include + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_TT_SYSTEM_GESTURES) + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include + +#include + +namespace tt::lvgl { + +namespace { + +constexpr auto* LAUNCHER_APP_ID = "tactility.launcher"; + +/** How long a bottom-edge swipe has to stay down before the recents switcher opens instead of going home. */ +constexpr uint32_t HOLD_TIME_MS = 500; +// How often the hold timer checks whether the swipe turned into a hold. It only exists while such a +// press is down. +constexpr uint32_t HOLD_POLL_MS = 50; + +// One press at a time: a second finger is ignored, which is what a single-touch panel reports anyway +struct GestureState { + lv_indev_t* indev = nullptr; + SystemGestureEdge edge = SystemGestureEdge::None; + lv_point_t press = { 0, 0 }; + uint32_t press_tick = 0; + bool hold_triggered = false; + lv_timer_t* hold_timer = nullptr; +}; + +GestureState state; +bool attached = false; + +bool isLauncher(AppInstanceId app_instance_id) { + char app_id[APP_MANIFEST_ID_LENGTH + 1]; + if (app_manager_get_instance_app_id(app_instance_id, app_id, sizeof(app_id)) != ERROR_NONE) { + return false; + } + return strcmp(app_id, LAUNCHER_APP_ID) == 0; +} + +/** @return the launcher's window, or WINDOW_ID_NONE when it isn't open */ +WindowId findLauncherWindow() { + WindowId found = WINDOW_ID_NONE; + forEachWindow([&found](const WindowInfo& window) { + if (!isLauncher(window.app_instance_id)) { + return true; + } + found = window.id; + return false; + }); + return found; +} + +/** Home: shows the launcher again, keeping the apps above it in the stack. */ +void showHome() { + const WindowId launcher = findLauncherWindow(); + if (launcher != WINDOW_ID_NONE) { + raiseWindow(launcher); + } +} + +/** + * Back: closes the app of the topmost window, which brings back the window below it. + * The window stack decides this, not the newest app instance: after going home, the launcher is on + * top while a newer app instance is buried below it. No-op on the launcher. + */ +void closeTopWindowApp() { + const WindowInfo top = getTopWindow(); + if (top.id == WINDOW_ID_NONE || isLauncher(top.app_instance_id)) { + return; + } + app_event_emit_close(top.app_instance_id); +} + +void clearPress() { + if (state.hold_timer != nullptr) { + // Safe in the timer's own callback too: LVGL handles a timer deleting itself + lv_timer_delete(state.hold_timer); + state.hold_timer = nullptr; + } + state.indev = nullptr; + state.edge = SystemGestureEdge::None; + state.hold_triggered = false; +} + +bool isPressStillDown() { + return state.indev != nullptr && lv_indev_get_state(state.indev) == LV_INDEV_STATE_PRESSED; +} + +// Keeps checking a bottom-edge press: a swipe up that keeps being held opens the recents switcher +void onHoldTimer(lv_timer_t* timer) { + if (!isPressStillDown()) { + // The release never reached us, e.g. when the display was reset mid-press + clearPress(); + return; + } + if (lv_tick_elaps(state.press_tick) < HOLD_TIME_MS) { + return; + } + + lv_point_t point; + lv_indev_get_point(state.indev, &point); + if (!systemGestureIsSwipeUp(state.press.x, state.press.y, point.x, point.y)) { + // Still down, but not swiped up far enough yet + return; + } + + // The finger is still down: the release must not click whatever is under it + state.hold_triggered = true; + lv_timer_pause(timer); + lv_indev_wait_release(state.indev); + systemRecentsShow(); +} + +lv_display_t* getIndevDisplay(lv_indev_t* indev) { + lv_display_t* display = lv_indev_get_display(indev); + return display != nullptr ? display : lv_display_get_default(); +} + +void onIndevPressed(lv_event_t* event) { + auto* indev = static_cast(lv_event_get_current_target(event)); + if (isPressStillDown()) { + // One press at a time: a second finger is ignored + return; + } + // A press that never reported its release must not block every press after it + clearPress(); + // A shown overlay (system menu, recents) has its own gestures + if (window_manager_get_active_layer() != WINDOW_LAYER_APP) { + return; + } + lv_display_t* display = getIndevDisplay(indev); + if (display == nullptr) { + return; + } + + lv_point_t point; + lv_indev_get_point(indev, &point); + const SystemGestureEdge edge = systemGestureEdgeAt( + point.x, + point.y, + lv_display_get_horizontal_resolution(display), + lv_display_get_vertical_resolution(display) + ); + if (edge == SystemGestureEdge::None) { + return; + } + // In the side layout, the statusbar strip already owns the left edge + if (edge == SystemGestureEdge::Left && systemBarsGetLayout() == settings::launcher::SystemBarsLayout::Side) { + return; + } + + state.indev = indev; + state.edge = edge; + state.press = point; + state.press_tick = lv_tick_get(); + state.hold_triggered = false; + + if (edge == SystemGestureEdge::Bottom) { + state.hold_timer = lv_timer_create(onHoldTimer, HOLD_POLL_MS, nullptr); + } +} + +void onIndevReleased(lv_event_t* event) { + auto* indev = static_cast(lv_event_get_current_target(event)); + if (state.indev == nullptr || indev != state.indev) { + return; + } + + const SystemGestureEdge edge = state.edge; + const lv_point_t press = state.press; + const bool hold_triggered = state.hold_triggered; + clearPress(); + + if (hold_triggered) { + return; + } + + lv_point_t point; + lv_indev_get_point(indev, &point); + + if (edge == SystemGestureEdge::Bottom) { + if (systemGestureIsSwipeUp(press.x, press.y, point.x, point.y)) { + // The release must not click the widget below the pointer + lv_indev_reset(indev, nullptr); + showHome(); + } + } else if (edge == SystemGestureEdge::Left) { + if (systemGestureIsSwipeRight(press.x, press.y, point.x, point.y)) { + lv_indev_reset(indev, nullptr); + closeTopWindowApp(); + } + } +} + +void forEachPointer(void (*action)(lv_indev_t* indev)) { + for (lv_indev_t* indev = lv_indev_get_next(nullptr); indev != nullptr; indev = lv_indev_get_next(indev)) { + if (lv_indev_get_type(indev) == LV_INDEV_TYPE_POINTER) { + action(indev); + } + } +} + +void removePointerCallbacks(lv_indev_t* indev) { + lv_indev_remove_event_cb_with_user_data(indev, onIndevPressed, nullptr); + lv_indev_remove_event_cb_with_user_data(indev, onIndevReleased, nullptr); +} + +// The pointer devices are removed by LVGL itself on shutdown, so detaching only has to undo the +// callbacks for the ones that are still there. +void addPointerCallbacks(lv_indev_t* indev) { + removePointerCallbacks(indev); + lv_indev_add_event_cb(indev, onIndevPressed, LV_EVENT_PRESSED, nullptr); + lv_indev_add_event_cb(indev, onIndevReleased, LV_EVENT_RELEASED, nullptr); +} + +} + +void systemGesturesAttach() { + if (attached) { + return; + } + // Keypad/encoder-only devices keep behaving exactly as before + if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { + return; + } + // shortcut: pointer devices added after boot (a USB HID mouse) don't get the system gestures. + // Covers every touch panel, which exists before this runs. + forEachPointer(addPointerCallbacks); + attached = true; +} + +void systemGesturesDetach() { + if (!attached) { + return; + } + clearPress(); + forEachPointer(removePointerCallbacks); + attached = false; +} + +} + +#else + +namespace tt::lvgl { + +void systemGesturesAttach() {} +void systemGesturesDetach() {} + +} + +#endif diff --git a/Tactility/Source/lvgl/SystemMenu.cpp b/Tactility/Source/lvgl/SystemMenu.cpp index df07d6555..f3bc7826e 100644 --- a/Tactility/Source/lvgl/SystemMenu.cpp +++ b/Tactility/Source/lvgl/SystemMenu.cpp @@ -1,6 +1,9 @@ #include +#include #include +#include +#include #include #include @@ -20,6 +23,7 @@ #include #include #include +#include #include @@ -27,13 +31,16 @@ #include #include +#include #include +#include #include #include #include #include #include +#include #ifdef ESP_PLATFORM #include @@ -142,6 +149,85 @@ static void setMeshEnabled(void* context) { } } +/** Persists the brightness the way the Display app does, off the LVGL task as it writes a file. */ +static void saveBrightness(void* context) { + const auto brightness = static_cast(reinterpret_cast(context)); + auto display_settings = settings::display::loadOrGetDefault(); + display_settings.backlightDuty = brightness; + settings::display::save(display_settings); +} + +// The brightness that the panel changed, saved once when it closes instead of on every slider step +struct BrightnessState { + uint8_t duty = 0; + bool changed = false; +}; + +static BrightnessState brightness_state; + +static void onBrightnessChanged(lv_event_t* event) { + auto* slider_box = lv_event_get_current_target_obj(event); + Device* backlight_device = tt::backlight::findDisplayBacklight(); + if (backlight_device == nullptr) { + return; + } + const auto brightness = static_cast(tt::backlight::toBrightness(backlight_device, lvgl_sliderbox_get_value(slider_box))); + backlight_set_brightness(backlight_device, brightness); + device_put(backlight_device); + brightness_state.duty = brightness; + brightness_state.changed = true; +} + +static void onPanelClosed(void* /*userData*/) { + if (!brightness_state.changed) { + return; + } + brightness_state.changed = false; + dispatchOnMainDispatcher(saveBrightness, reinterpret_cast(static_cast(brightness_state.duty))); +} + +static void setClockText(lv_obj_t* label) { + const time_t now = ::time(nullptr); + const tm* local = localtime(&now); + // Before the clock is set, the year is meaningless - the same rule the statusbar uses + if (local != nullptr && local->tm_year >= (2025 - 1900)) { + lv_label_set_text_fmt(label, "%d:%02d", local->tm_hour, local->tm_min); + } else { + lv_label_set_text(label, "--:--"); + } +} + +static void setWifiStatusText(lv_obj_t* label) { + if (!isWifiEnabled()) { + lv_label_set_text(label, "Wi-Fi off"); + return; + } + Device* device = nullptr; + if (device_get_first_by_type(&WIFI_TYPE, &device) != ERROR_NONE) { + lv_label_set_text(label, "Wi-Fi on"); + return; + } + WifiStationState station_state = WIFI_STATION_STATE_DISCONNECTED; + char ssid[33] = {}; + const bool connected = + wifi_get_station_state(device, &station_state) == ERROR_NONE && + station_state == WIFI_STATION_STATE_CONNECTED && + wifi_station_get_target_ssid(device, ssid) == ERROR_NONE; + device_put(device); + if (connected && ssid[0] != '\0') { + lv_label_set_text_fmt(label, "Wi-Fi: %s", ssid); + } else { + lv_label_set_text(label, "Wi-Fi on"); + } +} + +#if defined(CONFIG_TT_SYSTEM_GESTURES) +static void onRecentsClicked(lv_event_t* /*event*/) { + window_manager_overlay_hide(); + systemRecentsShow(); +} +#endif + // Changing the radio and device states can block, so it happens on the main task struct ToggleOption { @@ -237,21 +323,41 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) { lv_obj_set_gesture_bubble(parent, false); lv_obj_add_event_cb(parent, onBackdropGesture, LV_EVENT_GESTURE, nullptr); - // The card fits its content, up to most of the screen + // The panel hangs from the top edge, which is where the swipe that opens it comes from auto* card = lvgl_card_create(parent); - lv_obj_set_size(card, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_max_height(card, LV_PCT(90), LV_STATE_DEFAULT); + lv_obj_set_width(card, LV_PCT(100)); + lv_obj_set_height(card, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(card, LV_PCT(100), LV_STATE_DEFAULT); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_center(card); + lv_obj_set_style_pad_row(card, lvgl_theme_is_compact() ? 4 : 8, LV_STATE_DEFAULT); + + // The clock, with the Wi-Fi status next to it + auto* status_row = lv_obj_create(card); + lv_obj_set_size(status_row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(status_row, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(status_row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(status_row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(status_row, 8, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(status_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(status_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_scrollable(status_row, false); + + auto* clock = lv_label_create(status_row); + lv_obj_set_style_text_font(clock, lvgl_get_text_font(FONT_SIZE_LARGE), LV_STATE_DEFAULT); + setClockText(clock); + lv_obj_set_flex_grow(clock, 1); + + if (hasWifi()) { + auto* wifi_status = lv_label_create(status_row); + lv_obj_set_style_text_font(wifi_status, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); + setWifiStatusText(wifi_status); + } auto* grid = lv_obj_create(card); lv_obj_remove_style_all(grid); - lv_obj_set_size(grid, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - // A percentage can't limit the width, as the card's width depends on the grid. The tiles wrap at the limit. - const int32_t max_card_width = lv_display_get_horizontal_resolution(lv_obj_get_display(parent)) * 9 / 10; - const int32_t card_padding = lv_obj_get_style_pad_left(card, LV_PART_MAIN) + lv_obj_get_style_pad_right(card, LV_PART_MAIN); - lv_obj_set_style_max_width(grid, max_card_width - card_padding, LV_STATE_DEFAULT); + lv_obj_set_size(grid, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); const int32_t gap = lvgl_theme_is_compact() ? 8 : 16; @@ -273,6 +379,10 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) { if (hasMesh()) { createOption(grid, &mesh_option); } +#if defined(CONFIG_TT_SYSTEM_GESTURES) + auto* recents_tile = createTile(grid, LVGL_ICON_SHARED_APPS); + lv_obj_add_event_cb(recents_tile, onRecentsClicked, LV_EVENT_SHORT_CLICKED, nullptr); +#endif if (supportsPowerOff()) { auto* power_tile = createTile(grid, LVGL_ICON_SHARED_POWER_SETTINGS_NEW); lv_obj_add_event_cb(power_tile, onPowerOffClicked, LV_EVENT_SHORT_CLICKED, nullptr); @@ -280,17 +390,48 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) { // The arrow keys move between the tiles. Toggles that are focused directly would be switched by them. lvgl_grid_navigation_add(grid); - // A keyboard or encoder can't tap outside of the card or swipe up, so it needs a button to close the menu + // Brightness, when the display has a backlight that can be dimmed (not just switched on and off) + Device* backlight_device = tt::backlight::findDisplayBacklight(); + if (backlight_device != nullptr) { + if (tt::backlight::isDimmable(backlight_device)) { + uint8_t brightness = 0; + backlight_get_brightness(backlight_device, &brightness); + auto* brightness_slider = lvgl_sliderbox_create(card, 0, 100, 10, tt::backlight::toPercent(backlight_device, brightness)); + lv_obj_set_width(brightness_slider, LV_PCT(100)); + lvgl_sliderbox_add_value_changed_cb(brightness_slider, onBrightnessChanged, nullptr); + } + device_put(backlight_device); + } + + // A keyboard or encoder can't tap outside of the panel or swipe up, so it needs a button to close it if (lvgl_indev_exists(LV_INDEV_TYPE_KEYPAD) || lvgl_indev_exists(LV_INDEV_TYPE_ENCODER)) { auto* close_button = createTile(card, LVGL_ICON_SHARED_CLOSE); lv_obj_add_event_cb(close_button, onCloseClicked, LV_EVENT_SHORT_CLICKED, nullptr); lv_group_focus_obj(close_button); lv_obj_add_state(close_button, LV_STATE_FOCUS_KEY); } + + // Slides in from the top edge. The animation goes with the widget when it's deleted. + LvglThemeSettings theme_settings = {}; + lvgl_theme_get_settings(&theme_settings); + if (theme_settings.animations_enabled) { + lv_obj_update_layout(card); + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, card); + lv_anim_set_exec_cb(&animation, [](void* object, int32_t value) { + lv_obj_set_style_translate_y(static_cast(object), value, LV_STATE_DEFAULT); + }); + lv_anim_set_values(&animation, -lv_obj_get_height(card), 0); + lv_anim_set_duration(&animation, 180); + lv_anim_set_path_cb(&animation, lv_anim_path_ease_out); + lv_anim_start(&animation); + } } void systemMenuShow() { - window_manager_overlay_show(createWidgets, nullptr, nullptr); + brightness_state.changed = false; + window_manager_overlay_show(createWidgets, onPanelClosed, nullptr); } } diff --git a/Tactility/Source/lvgl/SystemRecents.cpp b/Tactility/Source/lvgl/SystemRecents.cpp new file mode 100644 index 000000000..554074209 --- /dev/null +++ b/Tactility/Source/lvgl/SystemRecents.cpp @@ -0,0 +1,208 @@ +#include + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_TT_SYSTEM_GESTURES) + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace tt::lvgl { + +namespace { + +/** How many cards the switcher shows: the most recently used apps. */ +constexpr size_t MAX_ENTRIES = 5; +struct RecentsEntry { + WindowId window; + AppInstanceId app_instance_id; + char name[APP_MANIFEST_NAME_LENGTH + 1]; + const char* icon; +}; + +/** Looks up which app owns a window, so a card action can act on it. */ +AppInstanceId findAppInstance(WindowId window) { + AppInstanceId found = 0; + forEachWindow([&found, window](const WindowInfo& candidate) { + if (candidate.id != window) { + return true; + } + found = candidate.app_instance_id; + return false; + }); + return found; +} + +void onCardClicked(lv_event_t* event) { + auto* card = lv_event_get_current_target_obj(event); + const auto window = fromWindowContext(lv_obj_get_user_data(card)); + // Hide first: the app's window is rebuilt below where the (deleted) overlay was + window_manager_overlay_hide(); + raiseWindow(window); +} + +void onCardGesture(lv_event_t* event) { + lv_indev_t* indev = lv_indev_active(); + if (indev == nullptr || lv_indev_get_gesture_dir(indev) != LV_DIR_TOP) { + return; + } + auto* card = lv_event_get_current_target_obj(event); + const auto window = fromWindowContext(lv_obj_get_user_data(card)); + const AppInstanceId app_instance_id = findAppInstance(window); + // The release must not click the card + lv_indev_wait_release(indev); + window_manager_overlay_hide(); + if (app_instance_id != 0) { + // The app closes itself, which raises whichever window is below it + app_event_emit_close(app_instance_id); + } +} + +void onBackdropGesture(lv_event_t* /*event*/) { + lv_indev_t* indev = lv_indev_active(); + if (indev != nullptr && lv_indev_get_gesture_dir(indev) == LV_DIR_TOP) { + // The release must not click the pressed widget + lv_indev_wait_release(indev); + window_manager_overlay_hide(); + } +} + +void onCloseClicked(lv_event_t* /*event*/) { + window_manager_overlay_hide(); +} + +void createCard(lv_obj_t* parent, const RecentsEntry& entry) { + auto* card = lvgl_card_create(parent); + lv_obj_set_size(card, LV_PCT(66), LV_PCT(100)); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, lvgl_theme_is_compact() ? 4 : 8, LV_STATE_DEFAULT); + lv_obj_set_scrollable(card, false); + lv_obj_set_user_data(card, toWindowContext(entry.window)); + lv_obj_add_event_cb(card, onCardClicked, LV_EVENT_SHORT_CLICKED, nullptr); + // Swiping up closes this card's app + lv_obj_set_gesture_bubble(card, false); + lv_obj_add_event_cb(card, onCardGesture, LV_EVENT_GESTURE, nullptr); + + auto* icon = lv_label_create(card); + lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_large_font(), LV_STATE_DEFAULT); + lv_label_set_text(icon, entry.icon); + + auto* name = lv_label_create(card); + lv_obj_set_style_text_font(name, lvgl_get_text_font(FONT_SIZE_DEFAULT), LV_STATE_DEFAULT); + lv_obj_set_width(name, LV_PCT(100)); + lv_obj_set_style_text_align(name, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); + lv_label_set_long_mode(name, LV_LABEL_LONG_MODE_WRAP); + lv_label_set_text(name, entry.name); +} + +/** Collects the most recent apps, topmost first. */ +size_t collectEntries(RecentsEntry* entries) { + const size_t total = window_manager_get_windows(nullptr, 0, 0); + const size_t shown = total > MAX_ENTRIES ? MAX_ENTRIES : total; + if (shown == 0) { + return 0; + } + + WindowInfo windows[MAX_ENTRIES]; + window_manager_get_windows(windows, total - shown, shown); + + size_t entry_count = 0; + for (size_t i = shown; i > 0; i--) { + const WindowInfo& window = windows[i - 1]; + char app_id[APP_MANIFEST_ID_LENGTH + 1]; + if (app_manager_get_instance_app_id(window.app_instance_id, app_id, sizeof(app_id)) != ERROR_NONE) { + continue; + } + ::AppManifest manifest {}; + if (app_manager_find_manifest(app_id, &manifest) != ERROR_NONE) { + continue; + } + RecentsEntry& entry = entries[entry_count++]; + entry.window = window.id; + entry.app_instance_id = window.app_instance_id; + entry.icon = ::tt::app::getAppIcon(manifest); + strncpy(entry.name, manifest.name, APP_MANIFEST_NAME_LENGTH); + entry.name[APP_MANIFEST_NAME_LENGTH] = '\0'; + } + return entry_count; +} + +void createWidgets(lv_obj_t* parent, void* /*userData*/) { + // Swiping up anywhere closes the switcher + lv_obj_set_gesture_bubble(parent, false); + lv_obj_add_event_cb(parent, onBackdropGesture, LV_EVENT_GESTURE, nullptr); + + auto* title = lv_label_create(parent); + lv_label_set_text(title, "Recent apps"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, lvgl_theme_is_compact() ? 4 : 8); + + auto* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_PCT(60)); + lv_obj_center(row); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(row, lvgl_theme_is_compact() ? 4 : 8, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(row, lvgl_theme_is_compact() ? 4 : 8, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_scroll_dir(row, LV_DIR_HOR); + + RecentsEntry entries[MAX_ENTRIES]; + const size_t entry_count = collectEntries(entries); + for (size_t i = 0; i < entry_count; i++) { + createCard(row, entries[i]); + } + + // A keyboard or encoder can't tap outside of a card or swipe up + if (lvgl_indev_exists(LV_INDEV_TYPE_KEYPAD) || lvgl_indev_exists(LV_INDEV_TYPE_ENCODER)) { + auto* close_button = lvgl_icon_button_create(parent); + lv_obj_align(close_button, LV_ALIGN_BOTTOM_MID, 0, lvgl_theme_is_compact() ? -4 : -8); + lv_obj_add_event_cb(close_button, onCloseClicked, LV_EVENT_SHORT_CLICKED, nullptr); + auto* close_icon = lv_label_create(close_button); + lv_obj_set_style_text_font(close_icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + lv_label_set_text(close_icon, LVGL_ICON_SHARED_CLOSE); + lv_group_focus_obj(close_button); + } +} + +} + +void systemRecentsShow() { + // Nothing to list (no app has a window) + if (window_manager_get_windows(nullptr, 0, 0) == 0) { + return; + } + window_manager_overlay_show(createWidgets, nullptr, nullptr); +} + +} + +#else + +namespace tt::lvgl { + +void systemRecentsShow() {} + +} + +#endif diff --git a/Tactility/Tests/Source/SystemGesturesTest.cpp b/Tactility/Tests/Source/SystemGesturesTest.cpp new file mode 100644 index 000000000..158104ade --- /dev/null +++ b/Tactility/Tests/Source/SystemGesturesTest.cpp @@ -0,0 +1,39 @@ +#include "doctest.h" +#include + +using namespace tt::lvgl; + +TEST_CASE("only drags that start in an edge strip belong to the system") { + // Left edge, including its last pixel + CHECK_EQ(systemGestureEdgeAt(0, 160, 240, 320), SystemGestureEdge::Left); + CHECK_EQ(systemGestureEdgeAt(SYSTEM_GESTURE_EDGE_SIZE - 1, 160, 240, 320), SystemGestureEdge::Left); + CHECK_EQ(systemGestureEdgeAt(SYSTEM_GESTURE_EDGE_SIZE, 160, 240, 320), SystemGestureEdge::None); + // Bottom edge, so apps keep the rest of their swipes and scrolls + CHECK_EQ(systemGestureEdgeAt(120, 320 - SYSTEM_GESTURE_EDGE_SIZE, 240, 320), SystemGestureEdge::Bottom); + CHECK_EQ(systemGestureEdgeAt(120, 319, 240, 320), SystemGestureEdge::Bottom); + CHECK_EQ(systemGestureEdgeAt(120, 320 - SYSTEM_GESTURE_EDGE_SIZE - 1, 240, 320), SystemGestureEdge::None); + // The bottom corner goes home, not back + CHECK_EQ(systemGestureEdgeAt(0, 319, 240, 320), SystemGestureEdge::Bottom); + // The top edge is handled by the statusbar itself + CHECK_EQ(systemGestureEdgeAt(120, 0, 240, 320), SystemGestureEdge::None); +} + +TEST_CASE("an upward swipe must clear the threshold and be mostly vertical") { + CHECK(systemGestureIsSwipeUp(120, 300, 120, 300 - SYSTEM_GESTURE_SWIPE_DISTANCE)); + CHECK_FALSE(systemGestureIsSwipeUp(120, 300, 120, 300 - SYSTEM_GESTURE_SWIPE_DISTANCE + 1)); + CHECK_FALSE(systemGestureIsSwipeUp(120, 260, 120, 300)); + // A diagonal with equal travel on both axes is ambiguous: the app keeps it + CHECK_FALSE(systemGestureIsSwipeUp(120, 300, 80, 260)); + CHECK_FALSE(systemGestureIsSwipeUp(120, 300, 70, 260)); + CHECK(systemGestureIsSwipeUp(120, 300, 130, 260)); +} + +TEST_CASE("a rightward swipe must clear the threshold and be mostly horizontal") { + CHECK(systemGestureIsSwipeRight(0, 160, SYSTEM_GESTURE_SWIPE_DISTANCE, 160)); + CHECK_FALSE(systemGestureIsSwipeRight(0, 160, SYSTEM_GESTURE_SWIPE_DISTANCE - 1, 160)); + CHECK_FALSE(systemGestureIsSwipeRight(40, 160, 0, 160)); + CHECK_FALSE(systemGestureIsSwipeRight(0, 160, 40, 200)); + CHECK(systemGestureIsSwipeRight(0, 160, 40, 150)); + // Equal travel on both axes is ambiguous: the app keeps it + CHECK_FALSE(systemGestureIsSwipeRight(0, 160, 40, 120)); +} diff --git a/docs/design-v2/SPEC.md b/docs/design-v2/SPEC.md new file mode 100644 index 000000000..b2ec8671d --- /dev/null +++ b/docs/design-v2/SPEC.md @@ -0,0 +1,324 @@ +# tactility-mobile · redesign v2 — especificação visual + +Status: **proposta para aprovação** (só mockups; nenhum código de firmware). +Alvo: ES3C28P (ESP32-S3), TFT 2.8" 240×320 retrato, ~143 ppi, touch capacitivo, LVGL. +Design novo, feito do zero; não deriva de `docs/design/`. + +Arquivos: + +``` +docs/design-v2/ +├── SPEC.md # este documento +├── render.mjs # gera PNGs + checks.json (Playwright + Chrome local) +├── src/index.html, styles.css # fonte dos mockups (9 telas, 240×320 cada) +├── src/fonts/ # Inter 400/500 woff2 (só para o mockup) +└── png/1x/*.png png/2x/*.png contact-sheet.png contact-sheet@2x.png checks.json +``` + +Reproduzir: `PW= CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node render.mjs` + +## 1. Ideia + +- Escuro, quieto, um único azul. O azul só aparece em **estado** (ativo, ligado, rodando, foco) e na **ação primária**, nunca como decoração. +- Elemento-assinatura: a **barra de gesto 36×4**. Aparece só onde existe um gesto de borda (lock: subir; painel rápido: fechar). Ensina o gesto sem precisar de texto. +- Na home, o relógio de 48 px fica à esquerda, com a data empilhada à direita na mesma linha de base. Isso ocupa uma linha só e sobra altura para a grade e para o espaço até a dock. +- Leveza como regra de design: **1 tamanho de ícone (24)** + 4 ícones de 16 px; nenhum ícone de 32 px foi necessário. Nenhuma imagem, sombra ou blur. A única transparência é a camada de escurecimento. + +## 2. Paleta + +| Token | Hex | Uso | +|---|---|---| +| `bg` | `#0E1116` | fundo de tela | +| `surface` | `#1A1F27` | cartões, tiles do grid, dock, painel, diálogo, teclas modificadoras | +| `surface-2` | `#242A33` | tiles do painel, teclas, botão secundário, trilho do slider | +| `line` / `line-soft` | `#242A33` / `#1F2530` | divisórias dentro de cartão / em listas sem cartão | +| `text` | `#E8ECF1` | texto e ícones principais | +| `text-2` | `#9AA4B2` | texto secundário, ícones de linha, legendas | +| `text-3` | `#8A94A3` | valores, chevrons, cadeado (mínimo absoluto) | +| `accent` | `#4C8DFF` | ligado/ativo, botão primário, preenchimento do slider, ponto de app aberto | +| `accent-text` | `#9CC0FF` | texto/ícone de destaque sobre `accent-soft` | +| `accent-soft` | `#1A2B45` | rede conectada; fim do gradiente do lock | +| `accent-line` | `#2B4C7E` | borda da rede conectada | +| `on-accent` | `#0B1118` | texto/ícone sobre `accent` | +| escurecimento | `#000000` a 60% | atrás do painel rápido e do diálogo | + +Contraste medido (WCAG; todo texto ≥ 4,5:1): + +| Par | Razão | +|---|---| +| `text` sobre `bg` / `surface` / `surface-2` / `accent-soft` | 15.94 / 13.95 / 12.17 / 12.00 | +| `text-2` sobre `bg` / `surface` / `surface-2` / fim do gradiente | 7.50 / 6.56 / 5.73 / 5.64 | +| `text-3` sobre `bg` / `surface` / `surface-2` | 6.16 / 5.39 / **4.71** (pior caso) | +| `accent-text` sobre `accent-soft` | 7.72 | +| `on-accent` sobre `accent` | 5.92 | + +O texto do app escurecido atrás do painel e do diálogo fica abaixo de 4,5:1 de propósito: ele está inerte. + +## 3. Tipografia + +Família única: **Inter**, pesos 400 e 500. Todo texto é Medium, exceto as legendas de 12 px (Regular). + +| Papel | Tamanho / altura de linha / peso | Onde | +|---|---|---| +| Relógio | 48 / 48 / 500, só `0123456789:`, tracking −1 | home, lock | +| Título | 22 / 26 / 500 | barras de ferramentas, Recents, título do diálogo, hora no painel | +| Corpo | 15 / 20 / 500 | linhas de lista, botões, corpo do diálogo, dia da semana na home | +| Controle denso | 14 / 16 / 500 | teclas, rótulos dos tiles do painel | +| Secundário | 13 / 16 / 500 | hora da barra de status, datas, valores, caminho, percentuais | +| Legenda | 12 / 14 / 400 | rótulos do grid, dicas, rodapé, "Available networks" | + +- A escala pedida é 13/15/22/48. O 14 entra só nos controles densos (teclas e tiles); o 12 só nas legendas. +- **Rótulos do grid são legenda (12 Regular), largura máxima de 52 px, 1 linha, com reticências.** + - Medido: o mais largo é "Grok Bot", com 49,4 px. Nenhum rótulo é truncado. + - O menor vão entre vizinhos é 12,2 px (Settings↔Grok Bot). + - Em 13 Medium, "Grok Bot" e "Terminal" teriam 54 px e seriam truncados, por isso caiu para 12. +- O texto interativo nunca fica abaixo de 14. Onde há um rótulo de 12 ou 13, quem recebe o toque é o tile ou a linha inteira, não o texto. + +## 4. Grade, raios, alvos de toque + +- **Raios:** 12 px em cartões (tiles do grid, dock, cartões de lista, cartões de Recents, painel, diálogo, rede conectada). 8 px em controles (botões, campo, teclas, tiles do painel). Exceções circulares: interruptor 10, polegar 10, barra de gesto 2. +- **Faixas fixas:** barra de status 0–24, barra de ferramentas 24–68, conteúdo a partir de y=76. Margem lateral de 12 (cartões) ou 16 (texto solto). +- **Alvos de toque:** + +| Alvo | Tamanho | +|---|---| +| Voltar, menu, interruptor | 44×44 | +| Linhas de lista e cartões de Recents | 44 de altura | +| Itens do grid e da dock | 60×64 e 60×56 | +| X das Recents | 40×44, ícone de 16 | +| Olho do campo de senha | 40×40 | +| Botões do diálogo e Join | 40 de altura | +| Linha do slider | 40 de altura | +| Tiles do painel | 68×52 + rótulo | +| Teclas | 23×40 | + + - As teclas têm 23 px de largura: é o único alvo abaixo de 32 e é inevitável com 10 teclas em 240 px. A altura de 40 px compensa. +- **Gestos só de borda:** faixa de 16 px em cada borda. + +| Gesto | Ação | +|---|---| +| Topo, arrastar para baixo | Painel rápido | +| Base, arrastar para cima | Home | +| Base, arrastar para cima e segurar | Recents | +| Esquerda, arrastar para a direita | Voltar | + + - Nenhum controle fica nos 20 px de baixo da home (a dock termina em y=300). + - Exceção conhecida: a última fileira do teclado encosta na borda de baixo. Toque funciona; só um arrasto maior que o limiar vira gesto. + +## 5. Ícones + +- Grade de 24, coordenadas inteiras, 1 cor (recolorida em tempo de desenho), traço de 2 px, pontas e junções arredondadas. +- Os 4 ícones de 16 px (Wi-Fi da barra de status, chevron, X, cadeado) também têm traço real de 2 px. +- A regra de 2,25 px em 32 continua valendo se um ícone de 32 for adicionado depois. Hoje nenhuma tela precisa de um. +- Como o traço de 2 px é centrado numa coordenada inteira, cada linha reta cobre exatamente 2 pixels inteiros e fica nítida em bitmap. +- O ícone `wifi` (arcos) significa "Wi-Fi". A força do sinal tem ícone próprio: 3 barras (`sig3`, `sig2`, `sig1`). + - Barra preenchida = sinal; barra só contornada = sinal que falta. + - Continua 1 cor, e as três variantes ficam alinhadas na mesma base. + +Inventário: 23 ícones de 24 px (settings, bot, wifi, sig×3, folder, hub, notes, terminal, camera, back, more, sun, vol, bt, recents, eye, shift, del, display, power, file) + 4 de 16 px (wifis, chev, close, lock). + +## 6. Telas — layout e restrições LVGL + +Coordenadas em px, na tela de 240×320. + +**01 Home** +- Barra de status: hora 13, Wi-Fi 16. +- Relógio 48 em (12, 28). À direita: "Friday" 15 e "24 October" 13 `text-2`, alinhados à direita em x=228. +- Grade em y=86: 4 colunas de 60 px, tile de 44 (12 de raio, `surface`), ícone de 24, rótulo 12 com 4 px de margem. Linhas de 64 + 8 de vão: 86–222. +- Dock em y=244–300 (x 8–232, `surface`, 12 de raio): 4 ícones de 24 nas **mesmas colunas da grade**. App aberto = ponto de 4 px `accent` a 6 px da base da dock. +- Folga entre a grade e a dock: **22 px**. +- LVGL: + - Grid em flex/grid com tamanhos fixos. + - A dock é um `lv_obj` filho da tela da home, não é um app. + - Os pontos leem a pilha de janelas. + - Sem scroll. + +**02 Lock** +- Gradiente vertical de 2 paradas: `#0E1116` até 45%, depois até `#1A2B45`. +- Wi-Fi de 16 em `text-2` no canto superior direito. +- Relógio 48 centralizado em y=100. Data 15 `text-2` em y=154. +- Legenda "Swipe up to unlock" 12 em y=276. Barra de gesto 36×4 em y=302. +- LVGL: + - Gradiente via `bg_grad`, sem imagem. + - Sem barra de status (a hora já é o relógio). + - Destrava com arrasto para cima a partir da borda de baixo. + +**03 Painel rápido** +- Abre por cima do app atual (no mockup, Files) com escurecimento de 60%. +- Painel com 240×230, `surface`, raio 12 só nos cantos de baixo, linha de 1 px na base. +- Hora 22 e data 13 em y=12. +- Sliders em y=48 e y=88: ícone 24, trilho de 6 px (`surface-2`, preenchimento `accent`), polegar de 20 px `text`, valor 13 `text-2`, linha tocável de 40. +- Tiles em y=136: 3×68 com vão de 6 (x = 12, 86, 160). Corpo de 68×52 com raio 8, ícone de 24 central. Rótulo 14 abaixo do tile, 6 px de margem. +- Estados dos tiles: + - Ligado: `accent` com ícone `on-accent`. + - Desligado: `surface-2` com ícone `text-2`. +- Barra de gesto em y=218: arrastar para cima fecha. +- Sem notificações. +- LVGL: + - O escurecimento é **um único** `lv_obj` de tela cheia, `bg_color #000`, `bg_opa 153`. Não há outra transparência. + - Os sliders são `lv_slider` com `ext_click_area` até 40 px. + - O app de trás não é redesenhado durante a animação. + +**04 Recents** +- **Fundo opaco `bg`, cobrindo também a barra de status.** Nada do app de trás aparece. +- Cabeçalho próprio de 40 px (y=28–68): "Recents" 22 em x=16. "Swipe up to close" 12 `text-2` alinhado à direita, na mesma linha de base. +- Lista começa em y=76 (8 px de folga): até 5 cartões de 208×44, vão de 4, terminando em y=312. +- Cartão: ícone 24, nome 15, X de 16 dentro de um alvo de 40×44. +- App ativo: barrinha `accent` de 3×20, raio 2, recuada 4 px da borda esquerda do cartão. Recuada, ela lê como indicador; colada na borda, leria como borda colorida. +- O cartão inteiro abre o app; só o X fecha. +- LVGL: + - É uma tela, não uma sobreposição com transparência. + - Os cartões são `lv_obj` clicáveis; o X é um filho com `LV_OBJ_FLAG_EVENT_BUBBLE` desligado. + - Sem miniaturas. + +**05 Settings** +- Barra de status + barra de ferramentas (voltar 44, título 22). +- Cartão em y=76–296 com 5 linhas de 44: Display 70%, Sound 40%, Wi-Fi HomeNet, Bluetooth Off, Power 5 min. +- Cada linha: ícone 24 `text-2`, rótulo 15, valor 13 `text-3`, chevron 16 `text-3`. +- Divisória `line` de 1 px recuada até x=48. +- LVGL: + - `lv_list` não é necessário: cartão com 5 filhos em flex de coluna. + - Valores atualizados por evento, sem polling na UI. + +**06 Wi-Fi** +- Barra de ferramentas: voltar, título, interruptor 36×20 (ligado = trilho `accent` com botão `text` à direita; desligado = trilho `surface-2` com botão `text-2` à esquerda) dentro de um alvo de 44×44. +- Rede atual em y=76: cartão de 216×56 `accent-soft` com borda `accent-line`. Ícone e "Connected" em `accent-text`; nome 15. +- "Available networks" 12 em y=144. +- Cartão de redes em y=162: 3 linhas de 44 com ícone de força, nome e cadeado de 16 `text-3` quando a rede é protegida. +- LVGL: + - A lista rola se houver mais de 3 redes. A rede atual fica fixa. + - Scan sem spinner animado: o estado vira texto ("Scanning…" 13 no lugar da legenda). + +**07 App genérico (Files)** +- Barra de ferramentas: voltar, título, menu ⋮ (44×44). +- Caminho "/sdcard/projects" 13 `text-2` em y=76. +- Lista **sem cartão**, ocupando a largura toda, a partir de y=96: linhas de 44 com ícone (pasta/arquivo), nome 15 e meta 13 `text-3` à direita; divisória `line-soft` a partir de x=52. +- Rodapé fixo em y=288–320 com linha no topo: "6 items · 2 folders" 12 centralizado. +- LVGL: só a lista rola; a barra de ferramentas e o rodapé ficam fixos. +- Este é o padrão de chrome para qualquer app. + +**08 Diálogo** +- Sobre a tela de Wi-Fi escurecida a 60%. +- Cartão de 216×164 **ancorado embaixo** em (12, 144), com 12 px de margem igual às laterais. Padding 16, raio 12, borda `line`. + - Os botões ficam ao alcance do polegar. + - O cartão da rede que vai ser esquecida continua visível acima, escurecido. Isso dá contexto à pergunta. + - O diálogo nunca corta texto do fundo pela metade. +- Título "Forget network?" 22 (cabe em 1 linha: 26 px medidos). +- Corpo 15 `text-2`, 2 linhas. +- Botões de 40 com vão de 8: Cancel (`surface-2`) e Forget (`accent`/`on-accent`). +- LVGL: + - `lv_msgbox` reestilizado, ou um `lv_obj` simples. + - O mesmo objeto de escurecimento do painel rápido. + +**09 Teclado (senha do Wi-Fi)** +- Barra de ferramentas: voltar + nome da rede 22. +- Campo de 160×40 (borda `accent` = foco) com pontos 15, cursor `accent` 2×18 estático e olho de 24 num alvo de 40×40. +- Botão "Join" 48×40 `accent` à direita do campo. +- Legenda "WPA2 · case-sensitive" 12 em y=124. +- Teclado em y=152–320: linha `line-soft` no topo + 4 px de respiro, teclas de y=157 a 320. São 4 fileiras de **40 px**, vão de **1 px**, letras **14**, raio 8. +- Fileiras: + - QWERTYUIOP: 10 teclas de 23 px. + - ASDFGHJKL: 9 teclas, recuo de meia tecla. + - Shift (1,5) + ZXCVBNM + apagar (1,5). + - 123 (2) + space (5,5) + Go (2,5). +- Cores das teclas: letras `surface-2`; modificadoras `surface` com `text-2`; Go `accent`. +- LVGL: + - `lv_keyboard` (buttonmatrix: **1 objeto** para todas as teclas) com mapa e `ctrl_map` próprios para larguras relativas. + - `pad_gap 1`. + - Sem balão de tecla pressionada (economiza redesenho). O estado pressionado segue a regra da seção 6.1. + +### 6.1 Estados (para todas as telas) + +Cada estado é só uma troca de cor de fundo, sem nova imagem nem transparência: + +| Estado | Regra | +|---|---| +| Pressionado (neutro) | o fundo sobe um degrau: `bg` → `surface` → `surface-2` → `accent-soft` | +| Pressionado (primário) | `accent` → `accent-text` (`#9CC0FF`); o texto continua `on-accent` | +| Desligado / indisponível | texto e ícone `text-3`, sem destaque; interruptor com trilho `surface-2` | +| Foco (só para encoder/teclado físico) | contorno de 2 px `accent`, afastado 2 px | +| Vazio | 1 linha 15 `text-2` centrada: Recents "No open apps", Files "This folder is empty", Wi-Fi "No networks found" | +| Carregando | texto estático 13 `text-2` ("Scanning…", "Joining…"), nunca spinner | +| Erro | linha 13 sob o campo, em `accent-text`, dizendo o que fazer: "Wrong password. Check it and try again." Sem vermelho: a paleta não tem cor de erro | + +Contraste extra: `on-accent` sobre `accent-text` = 10,3:1. + +## 7. Transições (só entre estados, nada em loop) + +| Transição | Duração | Tipo | +|---|---|---| +| Lock → app/home | 180 ms | lock desliza para cima (translate Y), `ease_out` | +| Abrir painel rápido | 160 ms | painel desliza do topo (translate Y −230 → 0) e o escurecimento aparece no mesmo tempo (opa 0 → 153) | +| Fechar painel rápido | 120 ms | reverso, `ease_in` | +| Abrir Recents | 150 ms | tela entra de baixo (translate Y +24 → 0), sem fade | +| Fechar app em Recents (X ou arrasto) | 120 ms | cartão sai para cima e os de baixo sobem 48 px | +| Abrir app | 140 ms | app entra deslizando 24 px + troca de tela (`lv_screen_load_anim` MOVE_LEFT curto) | +| Voltar | 140 ms | reverso de abrir app | +| Diálogo | 120 ms | escurecimento opa 0 → 153 e cartão translate Y +8 → 0 | +| Teclado | 140 ms | sobe de y=320 até y=156 | +| Interruptor / tile ligado | 100 ms | troca de cor + deslocamento do botão | +| Toque pressionado | 0 ms | troca de cor instantânea | + +- Nenhum spinner, pulso ou cursor piscando. +- Se as animações estiverem desligadas no tema, tudo vira instantâneo. +- Animar só translate e opacidade da camada de escurecimento. Nunca escala, blur ou opacidade de árvore inteira. + +## 8. Orçamento de flash e RAM + +### 8.1 Fontes (bitmap LVGL 4 bpp, sem kerning, sem compressão) + +Como foi estimado: +- Cada glifo foi medido no Chrome com Inter real: caixa de tinta → `ceil(w·h/2)` bytes de bitmap + 8 B de descritor (`lv_font_fmt_txt_glyph_dsc_t`), + ~1 KB de cmap/cabeçalho por fonte. +- Valores em `png/checks.json`. + +| Fonte | Subconjunto | Glifos | Flash | +|---|---|---|---| +| Inter Regular 12 | ASCII 0x20–0x7E | 95 | 4,0 KB | +| Inter Medium 13 | ASCII | 95 | 4,5 KB | +| Inter Medium 14 | ASCII | 95 | 5,0 KB | +| Inter Medium 15 | ASCII | 95 | 5,3 KB | +| Inter Medium 22 | ASCII | 95 | 9,3 KB | +| Inter Medium 48 | `0123456789:` | 11 | 5,3 KB | +| **Total fontes** | | 486 | **≈ 33,4 KB** | + +- Acentos do português (Latin-1, +96 glifos) só valem para 13 e 15 (texto de usuário): ≈ +9 KB. +- Kerning ligado: ≈ +1–2 KB por fonte. +- A compressão do `lv_font_conv` reduz ~20–30% e custa CPU por glifo. Não recomendo neste hardware. + +### 8.2 Ícones (imagens de alfa A4, 1 cor, recoloridas) + +| Tamanho | Qtde | Bytes cada (bitmap + `lv_image_dsc_t`) | Total | +|---|---|---|---| +| 24×24 A4 | 23 | 288 + 28 | 7,1 KB | +| 16×16 A4 | 4 | 128 + 28 | 0,6 KB | +| **Total ícones** | 27 | | **≈ 7,7 KB** | + +Em A2, o bitmap cai pela metade (≈ 3,9 KB no total), mas o traço diagonal de 2 px fica serrilhado. Recomendo A4. + +### 8.3 Demais recursos e RAM + +| Recurso | Flash | RAM | +|---|---|---| +| Fundos (sólidos + 1 gradiente) | 0 | gradiente: cache de ~0,6 KB (320 px × RGB565) só no lock | +| Escurecimento 60% | 0 | 0 (é um retângulo; custo é de CPU no blend, não de RAM) | +| Buffers de desenho LVGL | — | 2 × 240×32×2 B = **30 KB** (parciais, DMA, RAM interna) | +| Estilos compartilhados (~20 `lv_style_t`) | — | ≈ 1,5 KB | +| Objetos da tela mais pesada (Home ≈ 40 objs) | — | ≈ 5 KB | +| Pior caso app + sobreposição (Files + painel ≈ 60 objs) | — | ≈ 8 KB | +| Teclado (`lv_keyboard` = 1 buttonmatrix) | — | ≈ 1,5 KB | +| Fontes e ícones | const, em flash | **0** (lidos direto da flash) | + +**Gradiente do lock em RGB565:** o azul vai de 2 a 8 em 5 bits. São só ~6 degraus ao longo de ~176 px, e as faixas podem aparecer. Duas saídas: +- ativar o dithering de gradiente do LVGL, se a versão em uso oferecer; +- ou encurtar o gradiente para o terço de baixo, onde os degraus ficam mais estreitos. + +Validar na placa antes de aprovar o lock. + +**Total novo em flash ≈ 33,4 + 7,7 = ~41 KB** (≈ 50 KB com acentos e kerning). **RAM de UI ≈ 40 KB no pico**, dominada pelos buffers de desenho. Nenhum recurso exige PSRAM. + +## 9. Fora do escopo desta proposta + +- Código de firmware, temas LVGL e conversão real das fontes/ícones. +- Os números de flash são estimativas medidas; os definitivos saem do `lv_font_conv`. +- Telas de Display, Sound, Bluetooth e Power (só a raiz de Settings foi desenhada). +- Estados de erro e vazio estão descritos aqui, mas não desenhados. diff --git a/docs/design-v2/png/1x/dialog.png b/docs/design-v2/png/1x/dialog.png new file mode 100644 index 000000000..3185f908a Binary files /dev/null and b/docs/design-v2/png/1x/dialog.png differ diff --git a/docs/design-v2/png/1x/files.png b/docs/design-v2/png/1x/files.png new file mode 100644 index 000000000..8ecdf061e Binary files /dev/null and b/docs/design-v2/png/1x/files.png differ diff --git a/docs/design-v2/png/1x/home.png b/docs/design-v2/png/1x/home.png new file mode 100644 index 000000000..56e0ed145 Binary files /dev/null and b/docs/design-v2/png/1x/home.png differ diff --git a/docs/design-v2/png/1x/keyboard.png b/docs/design-v2/png/1x/keyboard.png new file mode 100644 index 000000000..9ac025c81 Binary files /dev/null and b/docs/design-v2/png/1x/keyboard.png differ diff --git a/docs/design-v2/png/1x/lock.png b/docs/design-v2/png/1x/lock.png new file mode 100644 index 000000000..ce916d032 Binary files /dev/null and b/docs/design-v2/png/1x/lock.png differ diff --git a/docs/design-v2/png/1x/quick.png b/docs/design-v2/png/1x/quick.png new file mode 100644 index 000000000..b7c1bc702 Binary files /dev/null and b/docs/design-v2/png/1x/quick.png differ diff --git a/docs/design-v2/png/1x/recents.png b/docs/design-v2/png/1x/recents.png new file mode 100644 index 000000000..114ebe23f Binary files /dev/null and b/docs/design-v2/png/1x/recents.png differ diff --git a/docs/design-v2/png/1x/settings.png b/docs/design-v2/png/1x/settings.png new file mode 100644 index 000000000..04bec8bb6 Binary files /dev/null and b/docs/design-v2/png/1x/settings.png differ diff --git a/docs/design-v2/png/1x/wifi.png b/docs/design-v2/png/1x/wifi.png new file mode 100644 index 000000000..3a1694f94 Binary files /dev/null and b/docs/design-v2/png/1x/wifi.png differ diff --git a/docs/design-v2/png/2x/dialog.png b/docs/design-v2/png/2x/dialog.png new file mode 100644 index 000000000..ff066ceef Binary files /dev/null and b/docs/design-v2/png/2x/dialog.png differ diff --git a/docs/design-v2/png/2x/files.png b/docs/design-v2/png/2x/files.png new file mode 100644 index 000000000..fff367036 Binary files /dev/null and b/docs/design-v2/png/2x/files.png differ diff --git a/docs/design-v2/png/2x/home.png b/docs/design-v2/png/2x/home.png new file mode 100644 index 000000000..b86199243 Binary files /dev/null and b/docs/design-v2/png/2x/home.png differ diff --git a/docs/design-v2/png/2x/keyboard.png b/docs/design-v2/png/2x/keyboard.png new file mode 100644 index 000000000..24c0d5581 Binary files /dev/null and b/docs/design-v2/png/2x/keyboard.png differ diff --git a/docs/design-v2/png/2x/lock.png b/docs/design-v2/png/2x/lock.png new file mode 100644 index 000000000..808a37e1a Binary files /dev/null and b/docs/design-v2/png/2x/lock.png differ diff --git a/docs/design-v2/png/2x/quick.png b/docs/design-v2/png/2x/quick.png new file mode 100644 index 000000000..d30539c62 Binary files /dev/null and b/docs/design-v2/png/2x/quick.png differ diff --git a/docs/design-v2/png/2x/recents.png b/docs/design-v2/png/2x/recents.png new file mode 100644 index 000000000..b1166f046 Binary files /dev/null and b/docs/design-v2/png/2x/recents.png differ diff --git a/docs/design-v2/png/2x/settings.png b/docs/design-v2/png/2x/settings.png new file mode 100644 index 000000000..bda93e7c0 Binary files /dev/null and b/docs/design-v2/png/2x/settings.png differ diff --git a/docs/design-v2/png/2x/wifi.png b/docs/design-v2/png/2x/wifi.png new file mode 100644 index 000000000..32d9aa7bb Binary files /dev/null and b/docs/design-v2/png/2x/wifi.png differ diff --git a/docs/design-v2/png/checks.json b/docs/design-v2/png/checks.json new file mode 100644 index 000000000..d0957a59b --- /dev/null +++ b/docs/design-v2/png/checks.json @@ -0,0 +1,152 @@ +{ + "gridLabels": [ + { + "text": "Settings", + "w": 46, + "shown": 46.25, + "l": 38.875, + "r": 85.125, + "truncated": false, + "gapRight": 12.2 + }, + { + "text": "Grok Bot", + "w": 49, + "shown": 49.40625, + "l": 97.296875, + "r": 146.703125, + "truncated": false, + "gapRight": 20.2 + }, + { + "text": "Wi-Fi", + "w": 30, + "shown": 30.25, + "l": 166.875, + "r": 197.125, + "truncated": false, + "gapRight": 31.8 + }, + { + "text": "Files", + "w": 26, + "shown": 26.234375, + "l": 228.875, + "r": 255.109375, + "truncated": false, + "gapRight": null + }, + { + "text": "Hub", + "w": 23, + "shown": 23.375, + "l": 50.3125, + "r": 73.6875, + "truncated": false, + "gapRight": 31.6 + }, + { + "text": "Notes", + "w": 33, + "shown": 33.375, + "l": 105.3125, + "r": 138.6875, + "truncated": false, + "gapRight": 19 + }, + { + "text": "Terminal", + "w": 49, + "shown": 48.671875, + "l": 157.65625, + "r": 206.328125, + "truncated": false, + "gapRight": 13.5 + }, + { + "text": "Camera", + "w": 44, + "shown": 44.28125, + "l": 219.859375, + "r": 264.140625, + "truncated": false, + "gapRight": null + } + ], + "grid": { + "x": 0, + "y": 86, + "w": 240, + "h": 136 + }, + "dock": { + "x": 8, + "y": 244, + "w": 224, + "h": 56 + }, + "recentsX": { + "x": 184, + "y": 76, + "w": 40, + "h": 44 + }, + "recentsList": { + "x": 16, + "y": 76, + "w": 208, + "h": 44 + }, + "key": { + "x": 0, + "y": 157, + "w": 23.1, + "h": 40 + }, + "keyboard": { + "x": 0, + "y": 152, + "w": 240, + "h": 168 + }, + "dialogBtn": { + "x": 29, + "y": 251, + "w": 87, + "h": 40 + }, + "dialog": { + "x": 12, + "y": 144, + "w": 216, + "h": 164 + }, + "qtile": { + "x": 12, + "y": 136, + "w": 68, + "h": 74 + }, + "slider": { + "x": 12, + "y": 48, + "w": 216, + "h": 40 + }, + "thumb": { + "x": 134.8, + "y": 58, + "w": 20, + "h": 20 + }, + "overflow": [], + "dialogTitleH": 26, + "fonts": { + "Regular 12 ASCII": 4087, + "Medium 13 ASCII": 4586, + "Medium 14 ASCII": 5077, + "Medium 15 ASCII": 5464, + "Medium 22 ASCII": 9527, + "Medium 48 \"0123456789:\"": 5473 + } +} \ No newline at end of file diff --git a/docs/design-v2/png/contact-sheet.png b/docs/design-v2/png/contact-sheet.png new file mode 100644 index 000000000..a1557f3ca Binary files /dev/null and b/docs/design-v2/png/contact-sheet.png differ diff --git a/docs/design-v2/png/contact-sheet@2x.png b/docs/design-v2/png/contact-sheet@2x.png new file mode 100644 index 000000000..d45e6c1c5 Binary files /dev/null and b/docs/design-v2/png/contact-sheet@2x.png differ diff --git a/docs/design-v2/render.mjs b/docs/design-v2/render.mjs new file mode 100644 index 000000000..24cfed21e --- /dev/null +++ b/docs/design-v2/render.mjs @@ -0,0 +1,65 @@ +// Renders every screen at 1x/2x, the contact sheet, and dumps layout checks + font sizes. +// Usage: PW= node render.mjs +import { createRequire } from 'node:module'; +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const { chromium } = createRequire(path.join(process.env.PW, 'x.js'))('playwright'); +const url = 'file://' + path.join(here, 'src/index.html'); +const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {}); + +for (const scale of [1, 2]) { + const page = await browser.newPage({ deviceScaleFactor: scale, viewport: { width: 2400, height: 420 } }); + await page.goto(url); + await page.evaluate(() => document.fonts.ready); + for (const id of ['home', 'lock', 'quick', 'recents', 'settings', 'wifi', 'files', 'dialog', 'keyboard']) + await page.locator('#s-' + id).screenshot({ path: `${here}/png/${scale}x/${id}.png` }); + await page.locator('main.board').screenshot({ path: `${here}/png/contact-sheet${scale === 2 ? '@2x' : ''}.png` }); + + if (scale === 1) { + const report = await page.evaluate(() => { + const box = (el) => el.getBoundingClientRect(); + const out = {}; + // grid labels: width and gap to the neighbour on the same row + const labels = [...document.querySelectorAll('#s-home .app .t12')].map((el) => ({ text: el.textContent, w: el.scrollWidth, shown: box(el).width, l: box(el).left, r: box(el).right })); + out.gridLabels = labels.map((a, i) => ({ ...a, truncated: a.w > a.shown + 0.5, gapRight: i % 4 < 3 ? +(labels[i + 1].l - a.r).toFixed(1) : null })); + const o = (sel) => { const s = document.querySelector(sel); const b = box(s), r = box(s.closest('.scr')); return { x: +(b.left - r.left).toFixed(1), y: +(b.top - r.top).toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1) }; }; + out.grid = o('#s-home .grid'); out.dock = o('#s-home .dock'); + out.recentsX = o('#s-recents .x'); out.recentsList = o('#s-recents .rc'); + out.key = o('#s-keyboard .k'); out.keyboard = o('#s-keyboard .kb'); + out.dialogBtn = o('#s-dialog > .dlg .btn'); out.dialog = o('#s-dialog > .dlg'); + out.qtile = o('#s-quick .qt'); out.slider = o('#s-quick .sl'); out.thumb = o('#s-quick .thumb'); + out.overflow = [...document.querySelectorAll('.ell')].filter((e) => e.scrollWidth > e.clientWidth).map((e) => e.textContent); + // dialog title must fit on one line + out.dialogTitleH = box(document.querySelector('#s-dialog > .dlg .t22')).height; + + // LVGL 4 bpp bitmap estimate: per glyph ceil(w*h/2) bytes + 8 B glyph descriptor, + ~1 KB cmap/header + const ascii = [...Array(95)].map((_, i) => String.fromCharCode(32 + i)); + const c = document.createElement('canvas').getContext('2d'); + const est = (weight, size, chars) => { + c.font = `${weight} ${size}px Inter`; + let bytes = 0; + for (const ch of chars) { + const m = c.measureText(ch); + const w = Math.ceil(m.actualBoundingBoxLeft + m.actualBoundingBoxRight), h = Math.ceil(m.actualBoundingBoxAscent + m.actualBoundingBoxDescent); + bytes += Math.ceil((w * h) / 2) + 8; + } + return Math.round(bytes + 1024); + }; + out.fonts = { + 'Regular 12 ASCII': est(400, 12, ascii), + 'Medium 13 ASCII': est(500, 13, ascii), + 'Medium 14 ASCII': est(500, 14, ascii), + 'Medium 15 ASCII': est(500, 15, ascii), + 'Medium 22 ASCII': est(500, 22, ascii), + 'Medium 48 "0123456789:"': est(500, 48, [...'0123456789:']), + }; + return out; + }); + writeFileSync(`${here}/png/checks.json`, JSON.stringify(report, null, 2)); + } + await page.close(); +} +await browser.close(); diff --git a/docs/design-v2/src/fonts/inter-latin-400-normal.woff2 b/docs/design-v2/src/fonts/inter-latin-400-normal.woff2 new file mode 100644 index 000000000..f15b025d6 Binary files /dev/null and b/docs/design-v2/src/fonts/inter-latin-400-normal.woff2 differ diff --git a/docs/design-v2/src/fonts/inter-latin-500-normal.woff2 b/docs/design-v2/src/fonts/inter-latin-500-normal.woff2 new file mode 100644 index 000000000..54f0a595d Binary files /dev/null and b/docs/design-v2/src/fonts/inter-latin-500-normal.woff2 differ diff --git a/docs/design-v2/src/index.html b/docs/design-v2/src/index.html new file mode 100644 index 000000000..6e7bd1c79 --- /dev/null +++ b/docs/design-v2/src/index.html @@ -0,0 +1,187 @@ + + + + +tactility-mobile v2 — mockups + + + + + + + +
+ + +
+
09:41
+
09:41
+
Friday
24 October
+
+
Settings
+
Grok Bot
+
Wi-Fi
+
Files
+
Hub
+
Notes
+
Terminal
+
Camera
+
+
+
+ + + + +
+
01Home
+ + +
+
+
09:41
+
Friday, 24 October
+
Swipe up to unlock
+
+
02Lock
+ + +
+
+
+
+
09:41
+
Fri, 24 Oct
+
70%
+
40%
+
Wi-Fi
+
Bluetooth
+
Recents
+
+
+
03Quick panel
+ + +
+
+ RecentsSwipe up to close +
+
Files
+
Grok Bot
+
Wi-Fi
+
Notes
+
Terminal
+
04Recents
+ + +
+
09:41
+
Settings
+
+
Display70%
+
Sound40%
+
Wi-FiHomeNet
+
BluetoothOff
+
Power5 min
+
+
05Settings
+ + +
+
09:41
+
Wi-Fi
+
+ +
HomeNet
Connected
+ +
+
Available networks
+
+
Cafe_5G
+
Office
+
Guest
+
+
06Wi-Fi
+ + +
+
09:41
+
Files
+
/sdcard/projects
+
+
firmware12 items
+
logs3 items
+
config.json2 KB
+
boot.log18 KB
+
+
6 items · 2 folders
+
07App (Files)
+ + +
+
+
+
+
Forget network?
+
You'll need the password to join HomeNet again.
+
CancelForget
+
+
08Dialog
+ + +
+
09:41
+
Cafe_5G
+
••••••
+
Join
+
WPA2 · case-sensitive
+
+
qwertyuiop
+
asdfghjkl
+
zxcvbnm
+
123spaceGo
+
+
09Keyboard
+ +
+ + + + diff --git a/docs/design-v2/src/styles.css b/docs/design-v2/src/styles.css new file mode 100644 index 000000000..6b6318fed --- /dev/null +++ b/docs/design-v2/src/styles.css @@ -0,0 +1,140 @@ +/* tactility-mobile v2 — 240x320 mockups. Every value maps 1:1 to an LVGL style. */ + +@font-face { font-family: "Inter"; src: url("fonts/inter-latin-400-normal.woff2") format("woff2"); font-weight: 400; font-display: block; } +@font-face { font-family: "Inter"; src: url("fonts/inter-latin-500-normal.woff2") format("woff2"); font-weight: 500; font-display: block; } + +:root { + --bg: #0E1116; + --surface: #1A1F27; + --surface-2: #242A33; + --line: #242A33; + --line-soft: #1F2530; + --text: #E8ECF1; + --text-2: #9AA4B2; + --text-3: #8A94A3; + --accent: #4C8DFF; + --accent-text: #9CC0FF; + --accent-soft: #1A2B45; + --accent-line: #2B4C7E; + --on-accent: #0B1118; + --r-card: 12px; + --r-ctl: 8px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: #07090C; + color: var(--text); + font: 500 15px/20px "Inter", sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* ---------- board (contact sheet) ---------- */ +.board { display: flex; gap: 24px; padding: 32px; width: max-content; } +.board figure { display: flex; flex-direction: column; gap: 10px; } +.board figcaption { font: 500 13px/16px "Inter"; color: var(--text-2); } +.board figcaption b { color: var(--accent-text); font-weight: 500; margin-right: 6px; } + +/* ---------- screen ---------- */ +.scr { position: relative; width: 240px; height: 320px; overflow: hidden; background: var(--bg); } +.abs { position: absolute; } + +/* type scale: 12 caption (400) · 13 secondary · 14 dense control · 15 body · 22 title · 48 clock */ +.t12 { font: 400 12px/14px "Inter"; } +.t13 { font: 500 13px/16px "Inter"; } +.t14 { font: 500 14px/16px "Inter"; } +.t15 { font: 500 15px/20px "Inter"; } +.t22 { font: 500 22px/26px "Inter"; letter-spacing: -0.2px; } +.t48 { font: 500 48px/48px "Inter"; letter-spacing: -1px; font-variant-numeric: tabular-nums; } +.c2 { color: var(--text-2); } +.c3 { color: var(--text-3); } +.ca { color: var(--accent-text); } +.ell { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* icons: stroke always lands on 2 px (2.25 px at 32) */ +.i { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.i16 { width: 16px; height: 16px; stroke-width: 3; } /* 3 units * 16/24 = 2 px */ +.i32 { width: 32px; height: 32px; stroke-width: 1.6875; } /* 1.6875 * 32/24 = 2.25 px */ + +/* ---------- status bar 0..24 ---------- */ +.sb { position: absolute; left: 0; top: 0; width: 240px; height: 24px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; } + +/* ---------- toolbar 24..68 ---------- */ +.tb { position: absolute; left: 0; top: 24px; width: 240px; height: 44px; display: flex; align-items: center; padding-right: 4px; } +.tb .ttl { flex: 1; min-width: 0; } +.hit { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; } + +/* ---------- list rows ---------- */ +.card { background: var(--surface); border-radius: var(--r-card); overflow: hidden; } +.row { position: relative; height: 44px; display: flex; align-items: center; gap: 12px; padding: 0 8px 0 12px; } +.row + .row::before { content: ""; position: absolute; left: 48px; right: 0; top: 0; height: 1px; background: var(--line); } +.row .lbl { flex: 1; min-width: 0; } +.row .i { color: var(--text-2); } +.row .chev { color: var(--text-3); margin-left: -4px; } + +/* ---------- gesture bar 36x4 (signature: shown wherever an edge swipe acts) ---------- */ +.gbar { position: absolute; left: 102px; width: 36px; height: 4px; border-radius: 2px; background: var(--text-2); } + +/* ---------- 01 home ---------- */ +.home-date { right: 12px; top: 38px; text-align: right; } +.grid { left: 0; top: 86px; width: 240px; display: grid; grid-template-columns: repeat(4, 60px); row-gap: 8px; } +.app { height: 64px; display: flex; flex-direction: column; align-items: center; } +.tile { width: 44px; height: 44px; border-radius: var(--r-card); background: var(--surface); display: flex; align-items: center; justify-content: center; } +.app .t12 { margin-top: 4px; max-width: 52px; line-height: 16px; color: var(--text); } +.dock { left: 8px; top: 244px; width: 224px; height: 56px; border-radius: var(--r-card); background: var(--surface); } +.dockrow { left: 0; top: 244px; width: 240px; height: 56px; display: grid; grid-template-columns: repeat(4, 60px); } /* same columns as the grid */ +.dockrow > span { position: relative; display: flex; align-items: center; justify-content: center; } +.dot { position: absolute; left: 50%; bottom: 6px; width: 4px; height: 4px; margin-left: -2px; border-radius: 2px; background: var(--accent); } + +/* ---------- 02 lock ---------- */ +.lock { background: linear-gradient(180deg, #0E1116 0%, #0E1116 45%, #1A2B45 100%); } + +/* ---------- 03 quick panel ---------- */ +.dim { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.6); } +.qp { left: 0; top: 0; width: 240px; height: 230px; background: var(--surface); border-radius: 0 0 var(--r-card) var(--r-card); border-bottom: 1px solid var(--line); } +.sl { left: 12px; width: 216px; height: 40px; display: flex; align-items: center; gap: 8px; } +.sl .i { color: var(--text-2); } +.track { position: relative; flex: 1; height: 6px; border-radius: 3px; background: var(--surface-2); } +.fill { position: absolute; left: 0; top: 0; height: 6px; border-radius: 3px; background: var(--accent); } +.thumb { position: absolute; top: -7px; width: 20px; height: 20px; margin-left: -10px; border-radius: 10px; background: var(--text); } +.sl .val { width: 32px; text-align: right; } +.qt { position: absolute; top: 136px; width: 68px; display: flex; flex-direction: column; align-items: center; gap: 6px; } +.qt .pad68 { width: 68px; height: 52px; border-radius: var(--r-ctl); background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--text-2); } +.qt.on .pad68 { background: var(--accent); color: var(--on-accent); } + +/* ---------- 04 recents ---------- */ +.rc { left: 16px; width: 208px; height: 44px; border-radius: var(--r-card); background: var(--surface); display: flex; align-items: center; gap: 12px; padding-left: 12px; } +.rc .lbl { flex: 1; min-width: 0; } +.rc .x { width: 40px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; color: var(--text-2); } +.rc.cur::before { content: ""; position: absolute; left: 4px; top: 12px; width: 3px; height: 20px; border-radius: 2px; background: var(--accent); } + +/* ---------- 06 wi-fi ---------- */ +.sw { width: 36px; height: 20px; border-radius: 10px; background: var(--accent); position: relative; } +.sw::after { content: ""; position: absolute; right: 2px; top: 2px; width: 16px; height: 16px; border-radius: 8px; background: var(--text); } +.net { left: 12px; top: 76px; width: 216px; height: 56px; border-radius: var(--r-card); background: var(--accent-soft); border: 1px solid var(--accent-line); display: flex; align-items: center; gap: 12px; padding: 0 8px 0 12px; } + +/* ---------- 07 files ---------- */ +.flist { left: 0; top: 96px; width: 240px; } +.flist .row { padding: 0 12px 0 16px; } +.flist .row::before, .flist .row + .row::before { content: ""; position: absolute; left: 52px; right: 0; top: auto; bottom: 0; height: 1px; background: var(--line-soft); } +.flist .row:last-child::before { display: none; } /* footer line already separates */ +.foot { left: 0; top: 288px; width: 240px; height: 32px; border-top: 1px solid var(--line); display: flex; align-items: center; justify-content: center; } + +/* ---------- 08 dialog ---------- */ +.dlg { left: 12px; top: 144px; /* bottom-anchored, 12 px margin: thumb reach, network card stays visible above */ width: 216px; padding: 16px; border-radius: var(--r-card); background: var(--surface); border: 1px solid var(--line); } +.btns { display: flex; gap: 8px; margin-top: 16px; } +.btn { flex: 1; height: 40px; border-radius: var(--r-ctl); display: flex; align-items: center; justify-content: center; } +.btn.sec { background: var(--surface-2); } +.btn.pri { background: var(--accent); color: var(--on-accent); } + +/* ---------- 09 keyboard ---------- */ +.field { left: 12px; top: 76px; width: 160px; height: 40px; border-radius: var(--r-ctl); background: var(--surface); border: 1px solid var(--accent); display: flex; align-items: center; padding-left: 12px; } +.caret { width: 2px; height: 18px; background: var(--accent); margin-left: 2px; } +.join { left: 180px; top: 76px; width: 48px; height: 40px; } +.kb { left: 0; top: 152px; width: 240px; height: 168px; border-top: 1px solid var(--line-soft); display: flex; flex-direction: column; gap: 1px; padding-top: 4px; } +.kr { display: flex; gap: 1px; height: 40px; } +.k { flex: 1 1 0; min-width: 0; border-radius: var(--r-ctl); background: var(--surface-2); display: flex; align-items: center; justify-content: center; font: 500 14px/16px "Inter"; } +.k.m { background: var(--surface); color: var(--text-2); } +.k.go { background: var(--accent); color: var(--on-accent); } +.pad { flex: 0.5 1 0; } diff --git a/docs/design/mockups/contact-sheet.html b/docs/design/mockups/contact-sheet.html new file mode 100644 index 000000000..2bd43f364 --- /dev/null +++ b/docs/design/mockups/contact-sheet.html @@ -0,0 +1,154 @@ + + + + + + + +Tactility mobile — contact sheet + + + +
+
+

Tactility mobile

+
+

Redesign

+

240 × 320 · 9 screens · 1x

+
+
+ +
+
+
Lock screen
+
01Lock
+
+
+
Home screen
+
02Home
+
+
+
Quick panel
+
03Quick panel
+
+
+
Recents switcher
+
04Recents
+
+
+
Settings
+
05Settings
+
+
+
Wi-Fi list
+
06Wi-Fi
+
+
+
Files app
+
07App
+
+
+
On-screen keyboard
+
08Keyboard
+
+
+
Forget network dialog
+
09Dialog
+
+
+ +
+ #0E1116 + #1A1F27 + #4C8DFF + Inter · 400 / 500 + 12 / 13 / 14 / 15 / 20 / 22 / 48 + 12 px cards · 8 px controls +
+
+ + diff --git a/docs/design/mockups/fonts/inter-latin-400-normal.woff2 b/docs/design/mockups/fonts/inter-latin-400-normal.woff2 new file mode 100644 index 000000000..f15b025d6 Binary files /dev/null and b/docs/design/mockups/fonts/inter-latin-400-normal.woff2 differ diff --git a/docs/design/mockups/fonts/inter-latin-500-normal.woff2 b/docs/design/mockups/fonts/inter-latin-500-normal.woff2 new file mode 100644 index 000000000..54f0a595d Binary files /dev/null and b/docs/design/mockups/fonts/inter-latin-500-normal.woff2 differ diff --git a/docs/design/mockups/index.html b/docs/design/mockups/index.html new file mode 100644 index 000000000..e9f424895 --- /dev/null +++ b/docs/design/mockups/index.html @@ -0,0 +1,548 @@ + + + + + + + +Tactility mobile — redesign mockups + + + + + + +
+
+
+

Tactility mobile

+

Redesign

+
+

240 × 320 · dark · Inter · 9 screens

+
+ +
+ + +
+
+
+
+
+ TACTILITY +
+ +
09:41
+
Friday, 24 October
+
+ + Swipe up or tap +
+
+
+
+
01Lock
+
+ + +
+
+
+
+
+ 09:41 + + +
+ +
+
09:41
+
Friday, 24 October
+
+ +
+
Settings
+
Grok Bot
+
Wi-Fi
+
Files
+
Hub
+
Notes
+
Terminal
+
Camera
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
02Home
+
+ + +
+
+
+
+
+
+
+
+ 09:41 + Fri 24 Oct +
+
+ + +
+
+ + +
+
+
+ + Wi-Fi + HomeNet +
+
+ + Bluetooth + Off +
+
+ + Recents + 3 apps +
+
+
+
+
+
03Quick panel
+
+ + +
+
+
+
+
+ Recents + Swipe up to close +
+
+
+ + Files + +
+
+ + Grok Bot + +
+
+ + Wi-Fi + +
+
+ + Notes + +
+
+ + Home + +
+
+
+
+
+
04Recents
+
+ + +
+
+
+
+
+ 09:41 + + +
+
+ + Settings +
+ +
+
+ + Display + Dark + +
+
+ + Sound + 60% + +
+
+ + Wi-Fi + HomeNet + +
+
+ + Bluetooth + Off + +
+
+ + Power + 5 min + +
+
+
+
+
+
05Settings
+
+ + +
+
+
+
+
+ 09:41 + + +
+
+ + Wi-Fi + + + +
+ +
+ +
+ HomeNet + Connected +
+ + +
+ + +
+
+ + Rowan + + +
+
+ + TP-Link_4A2C + + +
+
+ + Guest + +
+
+
+
+
+
06Wi-Fi
+
+ + +
+
+
+
+
+ 09:41 + + +
+
+ + Files + + +
+ +
Internal storage
+
+
+ + Documents + +
+
+ + Music + +
+
+ + Pictures + +
+
+ + notes.txt + 2.1 KB + +
+
+ +
+
+
+
07App
+
+ + +
+
+
+
+
+ 09:41 + + +
+
+ + Rowan + + Join +
+ +
Password
+
+ •••••••• + + +
+
WPA2 Personal
+ +
+
+ qwertyuiop +
+
+ asdfghjkl +
+
+ + zxcvbnm + +
+
+ 123 + space + Go +
+
+
+
+
+
08Keyboard
+
+ + +
+
+
+
+
+
+
Forget network?
+
This will disconnect from HomeNet. You can reconnect later.
+
+ Cancel + Forget +
+
+
+
+
09Dialog
+
+ +
+ +
+ Solid / 2-stop backgrounds + One accent · 12 px cards · 8 px controls + Icons 24 / 32 · 2 px stroke +
+
+ + + + diff --git a/docs/design/mockups/styles.css b/docs/design/mockups/styles.css new file mode 100644 index 000000000..db4147fad --- /dev/null +++ b/docs/design/mockups/styles.css @@ -0,0 +1,795 @@ +@font-face { + font-family: "Inter"; + src: url("fonts/inter-latin-400-normal.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: block; +} +@font-face { + font-family: "Inter"; + src: url("fonts/inter-latin-500-normal.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: block; +} + +:root { + --bg: #0e1116; + --surface: #1a1f27; + --surface-2: #242a33; + --line: #242a33; + --line-soft: #1f2530; + --text: #e8ecf1; + --muted: #9aa4b2; + --faint: #8a94a3; + --accent: #4c8dff; + --accent-text: #9cc0ff; + --accent-soft: #1a2b45; + --accent-line: #2b4c7e; + --on-accent: #0b1118; + --rad-card: 12px; + --rad-control: 8px; +} + +* { box-sizing: border-box; } + +html { background: #07090c; } + +body { + margin: 0; + background: #07090c; + color: #e8ecf1; + font-family: "Inter", sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: geometricPrecision; +} + +.sprite { position: absolute; width: 0; height: 0; overflow: hidden; } + +/* ------------------------------------------------------------------ page */ + +.page { width: max-content; margin: 0 auto; padding: 56px 64px 48px; } + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 48px; + width: 822px; + padding-bottom: 24px; + border-bottom: 1px solid #1a1f27; +} + +.eyebrow { + margin: 0 0 8px; + font-size: 12px; + line-height: 14px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #9aa4b2; +} + +h1 { + margin: 0; + font-size: 48px; + line-height: 52px; + font-weight: 500; + letter-spacing: -1.5px; + color: #e8ecf1; +} + +.meta { margin: 0 0 6px; font-size: 13px; line-height: 16px; color: #8a94a3; } + +.screens { + display: grid; + grid-template-columns: repeat(3, 242px); + gap: 40px 48px; + margin-top: 40px; +} + +.shot { margin: 0; } + +.frame { width: 242px; height: 322px; padding: 1px; background: #1f2530; } + +.phone { + position: relative; + width: 240px; + height: 320px; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-size: 15px; + line-height: 1.2; +} + +figcaption { + display: flex; + align-items: baseline; + gap: 10px; + width: 242px; + padding-top: 10px; + font-size: 12px; + line-height: 14px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #9aa4b2; +} + +figcaption .num { color: #4c8dff; } + +.page-foot { + display: flex; + gap: 32px; + width: 822px; + margin-top: 48px; + padding-top: 20px; + border-top: 1px solid #1a1f27; + font-size: 12px; + line-height: 14px; + color: #8a94a3; +} + +/* ---------------------------------------------------------------- icons */ + +.icon { + display: block; + width: 24px; + height: 24px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.icon--16 { width: 16px; height: 16px; stroke-width: 2.25; } +.icon--18 { width: 18px; height: 18px; stroke-width: 2.4; } +.icon--32 { width: 32px; height: 32px; stroke-width: 1.6875; } + +/* --------------------------------------------------------------- shared */ + +.screen-inner { position: absolute; inset: 0; background: var(--bg); } +.overlay-base { position: absolute; inset: 0; } +.dim { position: absolute; inset: 0; background: #000; opacity: 0.6; } + +.screen-lock { background: linear-gradient(180deg, #0e1116 0%, #141c29 100%); } + +.spacer { flex: 1; } + +.statusbar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 24px; + display: flex; + align-items: center; + padding: 0 14px 0 16px; + color: var(--muted); +} + +.statusbar .time { + font-size: 13px; + line-height: 24px; + font-weight: 500; + letter-spacing: 0.01em; +} + +.statusbar .icon { color: var(--text); } + +.toolbar { + position: absolute; + top: 24px; + left: 0; + right: 0; + height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; +} + +.toolbar::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: var(--line-soft); +} + +.icon-btn { + width: 44px; + height: 44px; + margin-left: -12px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + flex: none; +} + +.icon-btn--right { margin-left: 0; margin-right: -12px; } +.icon-btn--flush { margin-left: 0; } + +.toolbar .title { + font-size: 22px; + line-height: 26px; + font-weight: 500; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toolbar .action { + min-width: 44px; + height: 44px; + margin-right: -8px; + padding: 0 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + line-height: 20px; + font-weight: 500; + color: var(--accent); +} + +.list { background: var(--surface); border-radius: var(--rad-card); overflow: hidden; } + +.row { + position: relative; + height: 44px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; +} + +.row .icon { color: var(--muted); } + +.row .label { + flex: 1; + min-width: 0; + font-size: 15px; + line-height: 20px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row .value { + font-size: 13px; + line-height: 16px; + font-weight: 500; + color: var(--faint); + white-space: nowrap; +} + +.row .chev { color: var(--faint); } + +.row + .row::before { + content: ""; + position: absolute; + top: 0; + left: 46px; + right: 0; + height: 1px; + background: var(--line-soft); +} + +/* ----------------------------------------------------------------- lock */ + +.lock-top { + position: absolute; + top: 20px; + left: 0; + right: 0; + display: flex; + justify-content: center; +} + +.wordmark { + font-size: 12px; + line-height: 14px; + font-weight: 400; + letter-spacing: 0.18em; + color: var(--muted); +} + +.lock-wifi { position: absolute; top: 14px; right: 16px; color: var(--muted); } + +.lock-clock { + position: absolute; + top: 90px; + left: 0; + right: 0; + text-align: center; + font-size: 48px; + line-height: 52px; + font-weight: 500; + letter-spacing: -1.5px; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.lock-date { + position: absolute; + top: 152px; + left: 0; + right: 0; + text-align: center; + font-size: 13px; + line-height: 15px; + font-weight: 500; + color: var(--muted); +} + +.lock-hint { + position: absolute; + left: 0; + right: 0; + bottom: 44px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.hint-bar { width: 36px; height: 4px; border-radius: 2px; background: var(--accent); } + +.hint-label { + font-size: 12px; + line-height: 14px; + font-weight: 400; + letter-spacing: 0.04em; + color: var(--muted); +} + +.colon { color: var(--accent); margin: 0 1px; } + +/* ----------------------------------------------------------------- home */ + +.home-clock { position: absolute; top: 30px; left: 16px; right: 16px; } + +.home-clock .big { + font-size: 48px; + line-height: 52px; + font-weight: 500; + letter-spacing: -1.5px; + font-variant-numeric: tabular-nums; +} + +.home-clock .date { + margin-top: 2px; + font-size: 13px; + line-height: 15px; + font-weight: 500; + color: var(--muted); +} + +.app-grid { + position: absolute; + top: 106px; + left: 12px; + width: 216px; + display: grid; + grid-template-columns: repeat(4, 48px); + column-gap: 8px; + row-gap: 8px; +} + +.app { width: 48px; display: flex; flex-direction: column; align-items: center; } + +.tile { + width: 44px; + height: 44px; + border-radius: var(--rad-card); + background: var(--surface); + display: flex; + align-items: center; + justify-content: center; + color: var(--text); +} + +.app .label { + width: 52px; + max-width: 52px; + margin-top: 3px; + font-size: 13px; + line-height: 15px; + font-weight: 500; + letter-spacing: -0.02em; + color: var(--muted); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dock { + position: absolute; + left: 12px; + right: 12px; + bottom: 14px; + height: 56px; + border-radius: var(--rad-card); + background: var(--surface); + border: 1px solid #232a35; + display: flex; + align-items: center; + justify-content: space-evenly; +} + +.dock-item { + width: 44px; + height: 44px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--text); +} + +.dock-item .dot { width: 4px; height: 4px; border-radius: 50%; background: transparent; } +.dock-item.running .dot { background: var(--accent); } + +/* ----------------------------------------------------------- quick panel */ + +.sheet { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; + height: 246px; + padding: 0 12px; + border-radius: 0 0 var(--rad-card) var(--rad-card); + background: var(--surface); +} + +.handle { width: 32px; height: 4px; margin: 8px auto 0; border-radius: 2px; background: #3a4352; } + +.sheet-head { display: flex; align-items: baseline; justify-content: space-between; margin-top: 10px; } + +.sheet-time { font-size: 22px; line-height: 26px; font-weight: 500; letter-spacing: -0.01em; } +.sheet-date { font-size: 13px; line-height: 16px; font-weight: 500; color: var(--muted); } + +.qrow { height: 40px; display: flex; align-items: center; gap: 12px; margin-top: 10px; } +.qrow .icon { color: var(--muted); } + +.slider { position: relative; flex: 1; height: 6px; border-radius: 3px; background: var(--surface-2); } +.slider .fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; background: var(--accent); } +.slider .knob { + position: absolute; + top: 50%; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--text); + border: 2px solid var(--accent); + transform: translate(-50%, -50%); +} + +.qtiles { display: flex; gap: 6px; margin-top: 12px; } + +.qtile { + width: 68px; + height: 68px; + border-radius: var(--rad-control); + background: var(--surface-2); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--muted); +} + +.qtile-label { font-size: 14px; line-height: 16px; font-weight: 500; color: var(--text); } +.qtile-sub { font-size: 13px; line-height: 16px; font-weight: 500; color: var(--muted); } +.qtile--on { background: var(--accent-soft); } +.qtile--on .icon { color: var(--accent); } + +/* --------------------------------------------------------------- recents */ + +.recents-screen { background: var(--bg); } + +.recents-head { + position: absolute; + top: 28px; + left: 16px; + right: 16px; + height: 40px; + display: flex; + align-items: baseline; + align-content: flex-start; + justify-content: space-between; + border-bottom: 1px solid var(--line-soft); +} + +.recents-title { + font-size: 20px; + line-height: 24px; + font-weight: 500; + letter-spacing: -0.01em; +} + +.recents-hint { + font-size: 12px; + line-height: 14px; + font-weight: 400; + color: var(--muted); +} + +.recent-list { + position: absolute; + top: 76px; + left: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.recent-card { + position: relative; + height: 44px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 0 0 12px; + border-radius: var(--rad-card); + background: var(--surface); + overflow: hidden; +} + +.recent-card .icon { color: var(--text); } +.recent-name { flex: 1; min-width: 0; font-size: 15px; line-height: 20px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.close-hit { + width: 40px; + height: 44px; + margin-left: auto; + flex: none; + display: flex; + align-items: center; + justify-content: center; + color: var(--faint); +} + +.recent-card.current::before { + content: ""; + position: absolute; + left: 0; + top: 12px; + bottom: 12px; + width: 3px; + background: var(--accent); +} + +/* --------------------------------------------------------------- settings */ + +.settings-list { position: absolute; top: 80px; left: 16px; right: 16px; } + +/* ----------------------------------------------------------------- wi-fi */ + +.switch-hit { + width: 40px; + height: 44px; + flex: none; + display: flex; + align-items: center; + justify-content: center; +} + +.switch { + position: relative; + width: 36px; + height: 20px; + flex: none; + border-radius: 10px; + background: var(--accent); +} + +.switch::after { + content: ""; + position: absolute; + top: 2px; + left: 18px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #f4f6fa; +} + +.switch.off { background: var(--surface-2); } +.switch.off::after { left: 2px; background: var(--muted); } + +.net-current { + position: absolute; + top: 78px; + left: 16px; + right: 16px; + height: 60px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + border-radius: var(--rad-card); + background: var(--accent-soft); + border: 1px solid var(--accent-line); +} + +.net-current .icon { color: var(--accent); } +.net-current .chev { color: var(--muted); } + +.net-current-text { display: flex; flex-direction: column; gap: 2px; } +.net-current-text .name { font-size: 15px; line-height: 18px; font-weight: 500; } +.net-current-text .state { font-size: 13px; line-height: 16px; font-weight: 500; color: var(--accent-text); } + +.section-label { + position: absolute; + top: 150px; + left: 16px; + font-size: 13px; + line-height: 16px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.net-list { position: absolute; top: 164px; left: 16px; right: 16px; } + +.row--net { gap: 8px; } +.row--net .sig { color: var(--muted); } +.row--net .lock { color: var(--faint); } +.row--net + .row--net::before { left: 36px; } + +/* ------------------------------------------------------------------ app */ + +.path-label { + position: absolute; + top: 78px; + left: 16px; + font-size: 13px; + line-height: 16px; + font-weight: 500; + color: var(--muted); +} + +.file-list { position: absolute; top: 96px; left: 16px; right: 16px; } + +.list-footer { + position: absolute; + top: 284px; + left: 16px; + font-size: 12px; + line-height: 14px; + font-weight: 400; + color: var(--muted); +} + +/* ------------------------------------------------------------- keyboard */ + +.field-label { + position: absolute; + top: 74px; + left: 16px; + font-size: 13px; + line-height: 15px; + font-weight: 500; + color: var(--muted); +} + +.field { + position: absolute; + top: 88px; + left: 16px; + right: 16px; + height: 40px; + display: flex; + align-items: center; + padding: 0 0 0 12px; + border-radius: var(--rad-control); + background: var(--surface); + border: 1px solid var(--accent); +} + +.field .dots { font-size: 15px; line-height: 20px; letter-spacing: 3px; color: var(--text); } +.field .eye { color: var(--muted); } + +.eye-hit { + width: 40px; + height: 40px; + flex: none; + display: flex; + align-items: center; + justify-content: center; +} + +.field-hint { + position: absolute; + top: 133px; + left: 16px; + font-size: 13px; + line-height: 15px; + font-weight: 500; + color: var(--muted); +} + +.keyboard { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 171px; + display: flex; + flex-direction: column; + gap: 1px; + padding: 4px; + background: #12161d; + border-top: 1px solid var(--line); +} + +.krow { display: flex; gap: 1px; flex: 1; min-height: 0; } + +.key { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--rad-control); + background: var(--surface-2); + font-size: 14px; + line-height: 16px; + font-weight: 500; + color: var(--text); +} + +.key--mod { flex: 1.35; background: var(--surface); color: var(--muted); } +.key--num { flex: 1.4; } +.key--space { flex: 5; } +.key--go { flex: 1.6; background: var(--accent); color: var(--on-accent); } + +/* --------------------------------------------------------------- dialog */ + +.dialog { + position: absolute; + z-index: 2; + top: 69px; + left: 16px; + width: 208px; + padding: 16px; + border-radius: var(--rad-card); + background: var(--surface); + border: 1px solid #2e3644; +} + +.dialog-title { font-size: 22px; line-height: 26px; font-weight: 500; letter-spacing: -0.01em; } +.dialog-body { margin-top: 8px; font-size: 15px; line-height: 20px; color: var(--muted); } +.dialog-actions { display: flex; gap: 8px; margin-top: 16px; } + +.btn { + flex: 1; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--rad-control); + font-size: 15px; + line-height: 20px; + font-weight: 500; +} + +.btn--secondary { background: var(--surface-2); color: var(--text); } +.btn--primary { background: var(--accent); color: var(--on-accent); } diff --git a/docs/design/png/1x/app.png b/docs/design/png/1x/app.png new file mode 100644 index 000000000..82c135941 Binary files /dev/null and b/docs/design/png/1x/app.png differ diff --git a/docs/design/png/1x/dialog.png b/docs/design/png/1x/dialog.png new file mode 100644 index 000000000..621498e40 Binary files /dev/null and b/docs/design/png/1x/dialog.png differ diff --git a/docs/design/png/1x/home.png b/docs/design/png/1x/home.png new file mode 100644 index 000000000..0c93a9464 Binary files /dev/null and b/docs/design/png/1x/home.png differ diff --git a/docs/design/png/1x/keyboard.png b/docs/design/png/1x/keyboard.png new file mode 100644 index 000000000..e33944b1a Binary files /dev/null and b/docs/design/png/1x/keyboard.png differ diff --git a/docs/design/png/1x/lock.png b/docs/design/png/1x/lock.png new file mode 100644 index 000000000..0a4853c21 Binary files /dev/null and b/docs/design/png/1x/lock.png differ diff --git a/docs/design/png/1x/quick-panel.png b/docs/design/png/1x/quick-panel.png new file mode 100644 index 000000000..3897d5dc6 Binary files /dev/null and b/docs/design/png/1x/quick-panel.png differ diff --git a/docs/design/png/1x/recents.png b/docs/design/png/1x/recents.png new file mode 100644 index 000000000..0c228b4bd Binary files /dev/null and b/docs/design/png/1x/recents.png differ diff --git a/docs/design/png/1x/settings.png b/docs/design/png/1x/settings.png new file mode 100644 index 000000000..28420a2c7 Binary files /dev/null and b/docs/design/png/1x/settings.png differ diff --git a/docs/design/png/1x/wifi.png b/docs/design/png/1x/wifi.png new file mode 100644 index 000000000..6eeeece5c Binary files /dev/null and b/docs/design/png/1x/wifi.png differ diff --git a/docs/design/png/2x/app.png b/docs/design/png/2x/app.png new file mode 100644 index 000000000..67e1347f8 Binary files /dev/null and b/docs/design/png/2x/app.png differ diff --git a/docs/design/png/2x/dialog.png b/docs/design/png/2x/dialog.png new file mode 100644 index 000000000..70a321799 Binary files /dev/null and b/docs/design/png/2x/dialog.png differ diff --git a/docs/design/png/2x/home.png b/docs/design/png/2x/home.png new file mode 100644 index 000000000..183b19b80 Binary files /dev/null and b/docs/design/png/2x/home.png differ diff --git a/docs/design/png/2x/keyboard.png b/docs/design/png/2x/keyboard.png new file mode 100644 index 000000000..258c16a01 Binary files /dev/null and b/docs/design/png/2x/keyboard.png differ diff --git a/docs/design/png/2x/lock.png b/docs/design/png/2x/lock.png new file mode 100644 index 000000000..0bc331617 Binary files /dev/null and b/docs/design/png/2x/lock.png differ diff --git a/docs/design/png/2x/quick-panel.png b/docs/design/png/2x/quick-panel.png new file mode 100644 index 000000000..eb4571b46 Binary files /dev/null and b/docs/design/png/2x/quick-panel.png differ diff --git a/docs/design/png/2x/recents.png b/docs/design/png/2x/recents.png new file mode 100644 index 000000000..dc65da4b3 Binary files /dev/null and b/docs/design/png/2x/recents.png differ diff --git a/docs/design/png/2x/settings.png b/docs/design/png/2x/settings.png new file mode 100644 index 000000000..87dab8b1b Binary files /dev/null and b/docs/design/png/2x/settings.png differ diff --git a/docs/design/png/2x/wifi.png b/docs/design/png/2x/wifi.png new file mode 100644 index 000000000..55777727e Binary files /dev/null and b/docs/design/png/2x/wifi.png differ diff --git a/docs/design/png/contact-sheet.png b/docs/design/png/contact-sheet.png new file mode 100644 index 000000000..d88dfd0f7 Binary files /dev/null and b/docs/design/png/contact-sheet.png differ diff --git a/docs/design/redesign-spec.md b/docs/design/redesign-spec.md new file mode 100644 index 000000000..8e6523b1a --- /dev/null +++ b/docs/design/redesign-spec.md @@ -0,0 +1,380 @@ +# Tactility mobile — redesign spec + +Status: design complete (mockups + spec, no firmware changes) +Target: ESP32-S3 ES3C28P, 2.8" TFT, 240 × 320 portrait, ~143 ppi, RGB565, capacitive touch, LVGL +Source context: `docs/mobile/ios-gestures-spec.md` (window stack, quick panel, recents) +Review: independent design critique + `impeccable` audit/critique pass, `frontend-design` direction, `web-design-guidelines` accessibility checklist +Mockups: `docs/design/mockups/` · renders: `docs/design/png/` + +This spec describes a visual redesign of the nine front-end surfaces. It deliberately does **not** +change firmware. Where the redesign requires a firmware change (recents stack, dock, Settings app, +wake screen, icons, fonts, keyboard height), the delta is listed in §9. + +## 1. Intent + +A dark, instrument-like handheld UI: quiet surfaces, one blue accent, crisp type, and a single +memorable gesture target — the 48 px clock with its accent colon. The UI is dense enough for a 240 px +screen but never decorative: every colour, line and pixel earns its place. Inter replaces Montserrat; +the type scale is fixed for bitmap rendering. The accent appears only for state and action, never as +decoration. + +Physical scene: a small desk/handheld device, dim room, glanced at and touched with a thumb. Dark is a +functional choice (glare-free, low-power, high contrast at 143 ppi), not a stylistic default. + +## 2. Hard constraints (global) + +| Constraint | Value | +|---|---| +| Canvas | 240 × 320, portrait. 1× = 240 × 320; 2× = 480 × 640. | +| Font family | **Inter only** (Regular 400, Medium 500). No second family. | +| Text rendering | LVGL bitmap fonts, **4 bpp**; clock is a **48 px subset of `0123456789:`** only. | +| Icon set | One colour, A2 2 bpp alpha masks, recoloured at draw time. Stroke **2 px at 24 px**, **2.25 px at 32 px**; 1.8 px at 18 px; 1.5 px at 16 px. | +| Backgrounds | Solid `#0E1116`, or one 2-stop vertical gradient (lock only). No photos, no textures, no blur. | +| Alpha | One solid **60% black** dim layer for quick panel/dialog. Recents is **opaque**. No other runtime alpha. | +| Accent | Exactly one: `#4C8DFF`. Tint `#1A2B45`/`#9CC0FF` are precomputed solids, not runtime alpha. | +| Radii | **12 px** cards (tiles, list cards, recents cards, dock, dialog, sheet bottom); **8 px** controls (buttons, fields, keys, quick tiles); switch pill 10 px; slider track 3 px, knob round. | +| Motion | Transitions only, **≤ 200 ms**; translate only. No idle animations, spinners, pulses or blinking carets. Stop when animations are disabled. | +| Chrome | Status bar 24 px; toolbar 44 px; content starts 80 px; list rows 44 px. | +| Touch | 44 × 44 ideal; **40 × 40 minimum**; never below 32. Exceptions: keyboard keys are 40 px high but 21–24 px wide (11-key QWERTY on 240 px); recents close X is 40 × 44. | +| Gesture rails | 20 px bottom strip and 20 px left strip belong to the system on normal screens. Taps still work; drags do not. Overlays disable system gestures. | +| Contrast | Secondary text ≥ **#8A94A3** on `#0E1116` (≥ 4.5:1). On cards `#1A1F27`, nothing darker than `#8A94A3` (5.39:1). | +| Focus / selection | Accent is the only selection/active colour. No semantic red; destructive copy is explicit. | +| Shadows | None. Structure comes from 1 px lines, surface steps and spacing. | +| Image RAM | None: all fonts/icons are const flash; no decoded buffers, no thumbnails. | + +## 3. Tokens + +### 3.1 Palette + +| Token | Hex | Use | Contrast on bg | +|---|---|---|---| +| `bg` | `#0E1116` | screen base | — | +| `surface` | `#1A1F27` | cards, tiles, dock, sheet | — | +| `surface-2` | `#242A33` | tracks, secondary buttons, quick tiles | — | +| `line` / `line-soft` | `#242A33` / `#1F2530` | borders and 1 px dividers | — | +| `text` | `#E8ECF1` | primary text, icons | **15.9:1** | +| `muted` | `#9AA4B2` | secondary text, labels, icons | **7.5:1** | +| `faint` | `#8A94A3` | weak values, chevrons, locks (minimum) | **6.16:1** | +| `accent` | `#4C8DFF` | active state, actions, switch on, slider fill | **5.91:1** | +| `accent-text` | `#9CC0FF` | small accent text on accent-soft | **7.72:1** | +| `accent-soft` | `#1A2B45` | selected Wi-Fi card / active tile | — | +| `accent-line` | `#2B4C7E` | selected card border | — | +| `on-accent` | `#0B1118` | text/icon on accent fills | **5.92:1** | + +The lock gradient is `#0E1116 → #141C29` (2 stops, vertical). All other screens use solid `bg`. +Card tint values are precomputed solids; no runtime alpha is used outside the one 60% dim layer. + +### 3.2 Type scale (Inter) + +| Role | Size / line / weight | Use | +|---|---|---| +| Clock | **48 / 52 / 500** | lock/home clock, digits + colon only | +| Title | **22 / 26 / 500** | Settings, Wi-Fi, Files, Rowan, Forget network? | +| Recents title | **20 / 24 / 500** | mandated overlay exception | +| Body | **15 / 20 / 500** | rows, buttons, dialog body, recents names | +| Dense interactive | **14 / 16 / 500** | keyboard keys, quick-panel tile labels | +| Secondary | **13 / 15–16 / 500** | dates, values, status time, section labels, paths, app-grid labels | +| Caption | **12 / 14 / 400** | TACTILITY, recents hint, lock hint, list footer | + +Primary scale: **13 / 15 / 22 / 48**. The mandated additions are **12 (captions)**, **14 (keys/tiles)** +and **20 (recents title)**. 500 is used for everything except 12 px captions; 400 is reserved for +captions. + +Letter-spacing: clock `-1.5 px`; titles `-0.01em`; app-grid labels `-0.02em` (to fit 52 px); captions +`0.04–0.18em`; everything else default. No text is horizontally scaled; no dynamic text scaling. + +App-grid labels are **one line, 52 px max, ellipsised**; the tile is the 44 px control, the label is a +13 px caption. "App Hub" is labelled **Hub**; "Settings" and "Grok Bot" keep their full names and a +visible gap (5–6 px) at 240 px. + +## 4. Layout and chrome + +- **Status bar** 24 px: time 13/500 at x=16; Wi-Fi 24 px at right 14; no battery (board has none). +- **Toolbar** y=24, 44 px: back/close 24 px icon inside a **44 × 44** hit; title 22/500 at x=52; one + 44 × 44 action max; 1 px `line-soft` divider at y=68. +- **Content** starts at 80 px; cards use 16 px side margins; rows 44 px; dividers inset 46 px. +- **Rows**: icon 24, label 15/500, value 13/500, chevron 16, 12 px padding. +- **Bottom rail**: 20 px free of interactive items on normal screens. Home dock items end 24 px above + the bottom; the dock surface ends 14 px above. The recents overlay (gestures disabled) and the + keyboard bottom row are the documented exceptions. +- **Left rail**: 20 px; the toolbar back hit starts at x=0, so drags from the left are the system back + gesture while taps still hit the button. +- **Recents header**: its own 40 px band at y=28; the opaque screen covers the status bar. +- **States**: pressed tiles/rows step surface → surface-2; accent buttons → `#3F7BE0`; no animation. + Focus (keypad/encoder devices only) is a 2 px accent ring at 2 px offset, radius +2; touch screens + show no focus ring. Disabled controls keep `#8A94A3` text with no accent. Empty states read + "No networks", "Empty folder", "No active apps" at 15/500 muted. Loading (Wi-Fi scan) is a static + "Scanning…" 15/500, never a spinner. Errors use the accent outline plus explicit copy; there is no + red. Sliders accept a tap on the 40 px row/track as well as a drag; the lock accepts tap or swipe; + the quick panel dismisses by tap outside or swipe up; recents closes by X or swipe; back has the + toolbar button as well as the left-edge gesture. + +## 5. Components + +- **App tile**: 44 × 44, 12 radius, surface; icon 32; label 13/500, max 52, one line. Four columns of + 48 px with 8 px gaps, 12 px side margin; 2 rows. +- **Dock (part of home)**: 216 × 56, 12 radius, 14 px above bottom; four 44 × 44 items, 32 px icons, + 4 px `#4C8DFF` running dot. It is a component of the launcher window — **not a separate app/window**. +- **List card**: 12 radius; rows 44; 1 px inset dividers; no shadow. +- **Button**: 40 or 44 high, 8 radius; primary accent + `on-accent`; secondary surface-2; one accent. +- **Switch**: visual 36 × 20 pill, 16 knob; hit 40 × 44; on = accent. +- **Slider**: track 6 (radius 3), thumb 20, touch row 40; fill accent; tap the track to set, drag to + adjust; no numeric value text. +- **Quick tile**: 68 × 68, 8 radius, surface-2; active surface `accent-soft` + accent icon; label + 14/500; sub-label 13/500. +- **Dialog**: 208 wide, 12 radius, 16 padding; title 22; body 15/20; buttons 40; 60% dim. +- **Keyboard**: field 40; eye hit 40; 4 rows of 40 px keys, 1 px gaps, 8 radius, letters 14; + space/Go row; Go accent. +- **Recents card**: 44 high, 12 radius, 32 icon, 15 name; close X 40 × 44 hit with 18 icon; whole card + opens the app; current card 3 px accent bar. +- **Sheet**: quick panel 246 high, 12 px side padding, bottom radius 12; opaque surface. + +### 5.1 Interaction states + +- **Pressed**: neutral controls change to `surface-2`; accent controls to `#3A6FC4`; instant, no scale. +- **Focus** (keypad/encoder variants): 2 px accent ring, 2 px offset, radius follows the control; never + colour-only. The touch-only mockups show the resting state; the ring is a firmware state. +- **Disabled**: text/icon `#8A94A3` on `surface`; no accent; no alpha. +- **Empty**: Wi-Fi `No networks` 15/500 muted; Files `Empty folder`; recents is not opened when the + window stack is empty. +- **Loading**: `Scanning…` 13/500 muted, static; no spinner, no idle animation. +- **Error**: no red; accent outline plus explicit copy (for example `Wrong password. Try again.`). +- **Alternatives to gestures**: lock taps or swipes; sliders tap or drag; recents X or swipe up; quick panel + tap outside or swipe; app close via toolbar back or left-edge swipe. + +## 6. Screens + +### 01 · Lock + +Purpose: wake/boot glance screen. There is **no PIN or security**; tap anywhere or swipe up is the wake gesture. +Layout: 240 × 320; 2-stop vertical gradient; no status bar. `TACTILITY` 12/400 caption at top 20; +Wi-Fi 24 at top 14 right 16; clock 48/52 at y=90; date 13/15 at y=152; 36 × 4 accent bar + `Swipe up or tap` +12/400 at bottom 44. Unlock by swiping up or tapping anywhere; 160 ms slide-up, returning to the +previous window (home if none). +Constraints: no alpha, no status bar, no idle animation, one gradient only; the whole screen is the +unlock target, so the gesture has a tap alternative. + +### 02 · Home + +Purpose: launcher. Status bar 24 (time 13, Wi-Fi 24). Clock 48/52 at y=30; date 13/15; app grid at +y=106, 4 × 48 columns, 8 gaps, 12 side margin; 8 tiles (Settings, Grok Bot, Wi-Fi, Files, Hub, Notes, +Terminal, Camera). Labels 13/500, 52 max, one line. Dock at y=250, 216 × 56, 14 above bottom; 4 apps +(Grok Bot, Notes, Files, Camera), 32 icons, 4 px running dot. Grid ends 238; dock gap 12. Status bar +swipe-down opens the quick panel. +Constraints: solid bg; no alpha; dock is part of home; 20 px bottom rail free of items; no animation; +labels are captions (13) while the 44 px tile is the control; `Hub` replaces `App Hub`; Settings and +Grok Bot must not touch. + +### 03 · Quick panel + +Purpose: top sheet for time, brightness, volume, Wi-Fi/BT and recents. Full width, 246 high, 12 side +padding, bottom radius 12; 60% black dim over home. Handle; time 22/26 + date 13 at y=22. Two 40 px +slider rows (y=58 and 108): icon 24, track 6, thumb 20, fill accent. Three 68 × 68 tiles at y=160, +6 px gap, 12 margins: Wi-Fi (HomeNet, active), Bluetooth (Off), Recents (3 apps). Labels 14, subs 13. +Transition: 160 ms slide-in from top; instant out. Dismiss swipe-up/tap outside. Brightness row is +**conditional**: on ES3C28P the devicetree backlight is disabled, so the row is omitted and the sheet +is 200 high; the mockup shows the dimmable variant. +Constraints: the only alpha is the 60% dim (one full-screen layer, no blur); sheet opaque; no spinner; +no out-animation; recents tile opens the switcher. + +### 04 · Recents + +Purpose: window switcher, max 5, topmost first. **Opaque `#0E1116` full screen**, including the status +bar area; the app behind never shows. Header 40 px at y=28: title 20/24 at y=28; hint `Swipe up to +close` 12/400 right-aligned on the same baseline; 1 px line at y=68. List at y=76 (8 px gap): five +208 × 44 cards, 4 px gaps, 16 side margins; cards end at y=312 (8 px bottom). Card: 32 icon, 15 name, +close X 40 × 44 hit with 18 icon; current card 3 px accent bar. Whole card opens; X closes; swipe up on +a card closes; swipe up on the screen dismisses. No thumbnails (only the topmost window has widgets). +Transition: 140 ms slide-in; instant out. +Constraints: opaque, no dim, no status bar, no base app; header 40; title 20/24; hint 12; list 76; +8 px breathing; X 40 × 44; no scroll; the bottom 8 px is card (overlay gestures are disabled). + +> Design delta: this replaces the shipped horizontal 66%-wide carousel with a vertical stack so all +> five cards are visible at 240 px. The close X is an addition; swipe-up still closes. + +### 05 · Settings + +Purpose: root settings list. Status bar + toolbar (back 44, title `Settings` 22). One card at y=80: +five 44 px rows, ends 300 (20 px bottom rail). Display (Dark), Sound (60%), Wi-Fi (HomeNet), Bluetooth +(Off), Power (5 min). Icons 24; labels 15; values 13; chevrons 16; 1 px inset dividers. No section +labels on the root; sub-screens scroll. Back/swipe-right closes. No animation. +Constraints: single card; 44 rows; card ends 300; 20 px rail; no alpha. + +### 06 · Wi-Fi + +Purpose: network list. Status bar + toolbar: back 44, title `Wi-Fi` 22, refresh 44, switch 40 × 44 hit +(36 × 20 visual). Connected card at y=78, 208 × 60, accent-soft + accent-line; icon 24 accent; name 15; +state `Connected` 13 `#9CC0FF`; chevron 16. `AVAILABLE` 13 at y=150. List at y=164: three 44 px rows, +ends 296 (24 px bottom). Signal 16 (1/2/3 variants), name 15, lock 16, chevron 16. Scan on entry and +refresh. Tapping a row opens the password/keyboard or detail. No spinner animation. +Constraints: 3 rows visible; 44 rows; switch hit 40 × 44; refresh 44; no alpha; bottom 24. + +### 07 · App (Files) + +Purpose: generic app chrome. Status bar + toolbar: back 44, title `Files` 22, more 44. Path `Internal +storage` 13 at y=78; list at y=96 with four 44 px rows (Documents, Music, Pictures, notes.txt), ends +272; footer `4 items · 12.4 GB free` 12/400 at y=284. Rows: icon 24, label 15, value 13, chevron 16. +No animation; list scrolls under the fixed toolbar. +Constraints: 4 rows visible; 44; card 12; footer caption 12; bottom 20 rail. + +### 08 · Keyboard + +Purpose: text entry (Wi-Fi password shown). Status bar + toolbar: back 44, title `Rowan` 22, `Join` +15 in a 44 hit. `Password` 13 at y=74; field 40 at y=88 with dots 15 and eye 18 in a 40 × 40 hit; +`WPA2 Personal` 13 at y=133. Keyboard at y=149, 171 high: 4 rows of 40 px keys, 1 px gaps, 4 px +padding, 8 radius, letters 14. Rows: QWERTY 10, ASDF 9, ZXCV 7 + shift/backspace, `123`/space/`Go`. +Go accent. Instant show; no key pop-ups, no suggestions, no idle animation. +Constraints: keys 40 high, 1 gap, letters 14; key width 21–24 is the **only sub-32 touch exception**; +bottom row sits in the 20 px rail (taps work, drags go home); field 40; eye 40; keyboard 171. + +### 09 · Dialog + +Purpose: confirm destructive action (Forget network). 60% dim over Wi-Fi. Card 208 wide at x=16, y=69, +16 padding, 12 radius. Title 22/26; body 15/20 (3 lines); two 40 px buttons, 8 gap: Cancel (surface-2) +and Forget (accent/on-accent). Card ends 251. Transition: 140 ms slide-up; instant out. No blur, no +red; the destructive word is explicit. +Constraints: dim 60; title 22; body 15; buttons 40; one accent; no idle. + +## 7. Motion + +| Transition | Duration | Notes | +|---|---|---| +| Lock unlock | 160 ms | translate up; previous window retained | +| Quick panel in | 160 ms | translate from top; out instant (overlay deletes widgets) | +| Recents in | 140 ms | translate; out instant | +| Dialog in | 140 ms | 8 px translate; out instant | +| Keyboard show | 0 ms | instant; typing must not wait | +| App/window switch | 0 ms | immediate | +| Idle | none | no pulses, spinners, blinking carets | + +Respect the theme's animations setting; when off, all transitions are instant. No full-screen alpha +animation; no text scaling/rotation. + +## 8. Flash / RAM budget + +### 8.1 Fonts (Inter, 4 bpp, compiled; clock 4 bpp subset) + +| Asset | Size / weight | Subset | Glyphs | Est. flash | +|---|---|---|---|---| +| Caption | 12 / 400 | ASCII | 95 | 6.0 KB | +| Secondary | 13 / 500 | ASCII | 95 | 6.7 KB | +| Dense / keyboard | 14 / 500 | ASCII | 95 | 7.3 KB | +| Body | 15 / 500 | Latin-1 | 216 | 18.0 KB | +| Recents title | 20 / 500 | `Recents` | 7 | 1.6 KB | +| Title | 22 / 500 | ASCII | 95 | 14.4 KB | +| Clock | 48 / 500 | `0123456789:` | 11 | 7.2 KB | +| **Total** | | | **728** | **61.2 KB** | + +Estimates use the measured LVGL 4 bpp glyph density (157-glyph Montserrat sample) and include glyph +descriptors/cmaps. The 20 px subset is only `Recents`; if that title becomes dynamic, use the 22 px +subset instead (+13 KB). The 13/14/22 subsets are ASCII; extend 13/22 to Latin-1 (+8/+12 KB) if +localized app names or SSIDs appear in those roles — the 15 px body already covers user content. +Optional flash saving: render 12 captions with the 13 subset and 14 labels with the 15 subset +(−13.3 KB), at the cost of the exact mandated sizes. + +### 8.2 Icons (A2 2 bpp, one colour, recoloured at draw time) + +| Size | Count | Icons | Bytes each | Total | +|---|---|---|---|---| +| 32 | 9 | settings, bot, wifi, folder, package, note, terminal, camera, home | 264 | 2,376 | +| 24 | 13 | sun, volume, bluetooth, recents, back, more, refresh, wifi, folder, display, power, shift, backspace | 152 | 1,976 | +| 18 | 2 | close, eye | 89 | 178 | +| 16 | 5 | chevron, lock, signal-1, signal-2, signal-3 | 72 | 360 | +| Descriptors | 29 | `lv_image_dsc_t` | 32 | 928 | +| **Total** | **29** | | | **5.8 KB** | + +The 3 signal variants use 30% alpha for unfilled bars; still one colour. No per-state images: active, +muted and accent states are recolours of the same A2 mask. Strokes: 2 px at 24, 2.25 px at 32, 1.8 px +at 18, 1.5 px at 16. + +### 8.3 Other assets and RAM + +- Backgrounds: **0 bytes**; solid fill or a 2-stop gradient computed at draw (lock only). +- Dim layer: **0 bytes**; one 240 × 320 60% black layer, 76,800 px composited per paint, only while + quick panel/dialog is open. Recents uses no layer (opaque screen). +- Images: **none** (no photos, no thumbnails, no decoded buffers). +- Static descriptors: 29 × 32 B ≈ **0.9 KB**. +- Screen heap (LVGL objects, estimates; one screen + overlay at a time): lock 1.5 KB; home 7 KB; + quick panel 11 KB; recents 8 KB; settings 6 KB; Wi-Fi 6 KB; Files 6 KB; keyboard **20 KB peak**; + dialog 7 KB. +- **Total new flash ≈ 61.2 + 5.8 = 67.0 KB.** Net ≈ **+37 KB** after removing the built-in + Montserrat 14 (≈11.3 KB) and the AdwaitaSans TTF (≈18.9 KB); the Material Symbols TTF (20.9 KB) + stays for apps that still use glyph icons. That is <2% of the 3.88 MB firmware image. Fonts/icons + are const flash: **0 bytes of decoded RAM**, no runtime font generation. + +## 9. Implementation deltas (for the next spec/implementation pass) + +- **Inter bitmap subsets** replace the runtime-generated AdwaitaSans text fonts; the 48 px clock is + a digits+colon subset. Text is 4 bpp. +- **New A2 icon set** replaces the shell's Material Symbols glyphs; app manifests need an icon id and + the system app mapping changes. The Material Symbols font can remain for third-party apps. +- **Dock is part of home** (not a window/app); running dots read the window stack. +- **Recents**: opaque full-screen overlay, 40 px header, vertical 5-card stack, 40 × 44 close X, + whole card opens; replaces the shipped horizontal carousel and the dimmed backdrop. No thumbnails. +- **Settings** is promoted to an app with a toolbar/back (the shipped settings mode lives inside the + launcher); the home grid can launch it. +- **Lock/wake screen** is new: no PIN, shown on boot/wake, swipe up returns to the previous window. +- **Quick panel**: 246 high (200 without dimmable brightness), 68 px tiles, 6 px gaps, 40 px slider + rows; brightness row hidden on ES3C28P today (backlight disabled). +- **Keyboard**: 40 px keys, 1 px gaps, 171 px high, 149..320; skinned `lv_keyboard`, no new widget. +- **Toolbars**: 44 × 44 hit targets, 22 px titles, 24 px statusbar Wi-Fi, 13 px status time. +- **Theme/radii**: primary `#4C8DFF`, bg `#0E1116`, 12/8 radii, dark surfaces; the current Material + theme defaults must be overridden. +- No firmware code is included in this change. + +## 10. Verification and files + +Renders verified: nine PNGs at **240 × 320 (1×)** and **480 × 640 (2×)**, contact sheet at **1×** +(900 × 1310 canvas, 3 × 3 screens), no overflow/clipping, all text legible at 1×, contrast checked, +touch targets checked, one font family, one accent, no photos/blur. + +``` +docs/design/ +├── redesign-spec.md +├── mockups/ +│ ├── index.html # 9 screens, 240 × 320 each +│ ├── styles.css # tokens + screen CSS +│ ├── contact-sheet.html # 1× contact sheet source +│ └── fonts/ # Inter 400/500 woff2 (sources only; not firmware assets) +└── png/ + ├── 1x/*.png # 240 × 320 + ├── 2x/*.png # 480 × 640 + └── contact-sheet.png # 900 × 1310, 1× +``` + +Reproduce with Playwright Chromium headless from a temp directory (never in the firmware build): +`index.html` with `deviceScaleFactor` 1 and 2, screenshot each `.phone`; then `contact-sheet.html` at +1× with `fullPage`. The fonts are local woff2; no network at render time. + +### Review notes (impeccable audit/critique + independent critique) + +Resolved in this pass: recents ghosting/statusbar bleed → opaque screen, 40 px header, title 20/24 at +y=28, hint 12 on the same baseline, list at 76 with 8 px breathing; touch targets → 40–44 px +throughout, X 40 × 44, sliders 40/6/20, keyboard 40 px keys/1 px gap/14 px, dialog 40 px buttons, +4 px dock dot; typography → Inter 400/500, 13/15/22/48 primary scale with mandated 12/14/20, grid +labels one line/52 max (full names; `Hub`); contrast → `#8A94A3` minimum, `#9CC0FF` on accent-soft; +icons → 2/2.25 px strokes, one colour, A2; home date 13/15, grid up 8 px, dock breathing 12 px; lock +`TACTILITY` as a 12 px caption; quick-panel tiles 68/6; Wi-Fi refresh + switch hit areas; keyboard +field/eye targets; dialog body 15. + +Audit: the impeccable detector found only mandated single-family/Inter notices and contact-sheet +document patterns (flat doc hierarchy, numbered index); no contrast, touch-target or overflow findings. +The accessibility checklist (contrast, touch, alt text, focus/reduced-motion, tap alternatives) is +satisfied or explicitly documented below. The mockup HTML is a static design board, not the shipped +LVGL DOM; ARIA and web form rules do not map to the firmware. + +Audit result: the `impeccable` detector returned only font-family warnings (Inter/single family), +which the brief mandates, plus contact-sheet document warnings (the 12/13/22 page scale and the +01–09 index); it found no contrast, touch or overflow defects. The visual critique found no remaining +P0/P1 after the mandatory corrections. The web checklist added a tap alternative for the lock, focus/ +pressed/disabled/empty/loading states, and contact-sheet image dimensions/alt text. + +Residual risks / deliberate exceptions: keyboard key width 21–24 px (10/9-key QWERTY at 240 px; the +mandate fixes height at 40, not width); app-grid labels are 13 px captions (the 44 px tile is the +control) so the full names fit 52 px; the 60% dim is one full-screen composite (no blur; keep it +static); brightness is absent on ES3C28P; recents is opaque rather than a dim; Settings, lock and +vertical recents are new behaviours for the implementation pass, not shipped firmware. + +Skills used: `impeccable` (critique / audit / polish / typeset / layout; product register) and +`frontend-design` (subject-grounded direction, one-family type, restraint), with +`web-design-guidelines` only as an accessibility checklist. `liquid-glass-design` ignored. diff --git a/docs/mobile/ios-gestures-retro.md b/docs/mobile/ios-gestures-retro.md new file mode 100644 index 000000000..56fffb132 --- /dev/null +++ b/docs/mobile/ios-gestures-retro.md @@ -0,0 +1,83 @@ +# Retro: iOS-style gestures session + +Session scope: design (grilling) → spec → tickets → implementation → review → retrofit fixes, on +branch `feat/ios-gestures`. This document records improvements to the **environment** (repo, tooling, +steering files), not to the feature. Ordered by severity. + +## 1. The repo's test target cannot be built on a stock macOS checkout (guardrail gap) + +`tests.yml` builds everything through `cmake --build build --target build-tests`; locally that fails +in two ways before it ever reaches a test: + +- `Tests/Tactility/CMakeLists.txt` (and the other generated-asset steps) invoke `python`, which does + not exist on macOS. A `python3` symlink is needed on `PATH`. +- mbedtls' `generate_driver_wrappers.py` needs `jsonschema`, which is not in the system python. + +Effect: the only automated guardrail for this kind of change (pure-logic tests in the Tactility suite) +is unreachable locally. The committed gesture test was therefore verified by compiling that single +translation unit with doctest directly (20 assertions, passing), not through the project's test +target. + +Suggested fix: document (and script) the local test prerequisites - a `requirements-dev.txt` plus a +`tt.py test` command that creates a venv and points `python` at it. The current CI already proves the +build works on Linux; the gap is purely the local path. + +## 2. `.clang-format` exists, but nothing enforces it (existing check, unwired) + +The repo ships `.clang-format`, yet neither `build.yml` nor `tests.yml` runs it, and there is no +pre-commit hook. The diff happened to be format-clean when checked by hand, but nothing would have +caught it if it were not. + +Suggested fix: one small CI job (or pre-commit hook) running +`clang-format --dry-run --Werror` on changed files. That is a mechanical standard, so it belongs in a +deterministic check rather than in a review checklist. + +## 3. Navigation pointer: where "which app is showing" is answered + +The hardest fact to establish in this session was that the window manager keeps **only the topmost +window's widgets** and rebuilds a buried window through its `create_widgets` callback, while the app +scheduler never stops a running app. Those two facts together are the whole "app stack" model, and +they are spread across `window_manager.h`, `scheduler.cpp` and `manager.cpp`. + +Suggested fix: a two-line navigation pointer in `app/manager.h` (the header every app-facing +integrator reads) saying that visibility and ordering live in the window manager's stack, and that a +buried app keeps running. Cheap context load, removes an exploration detour. + +## 4. Mechanical rule worth turning into a check: raising off the LVGL task + +`window_manager_raise()` must not run on the LVGL task (lock-order inversion against +`window_manager_create()`/`remove()`). The diff encodes this with a helper (`raiseWindow()`) and a +`@warning`, and the reviewer had to re-derive it. The existing overlay APIs are safe from LVGL +callbacks, so "call the window manager from a widget callback" looks safe until it deadlocks. + +Suggested fix: an assertion in `window_manager_raise()` that fails loudly when called with the LVGL +lock held by the current task (the LVGL port exposes a recursive lock; a debug-only `lvgl_try_lock` +probe distinguishes the cases). That converts a comment into a deterministic trap. + +## 5. Tool economy: `idf.py size` re-runs the build + +`idf.py size` regenerates assets/fonts and relinks before printing the table, which cost about a +minute per measurement (before and after). Reading `build/Tactility.map` directly with +`python -m esp_idf_size` gives the same numbers without touching the build. Worth a note next to the +size instructions if size comparisons become routine (they are part of embedded reviews here). + +## 6. Information access: the brightness row cannot be seen on the target board + +`Devices/es3c28p/es3c28p.dts` disables `display_backlight`, so the quick panel correctly hides the +brightness slider on the only device this branch targets. The feature's most visible new control is +therefore unverifiable until a board with a dimmable backlight is available, or the devicetree node is +enabled in a hardware session. + +Suggested fix: none in code (deliberately out of scope); a follow-up ticket to test on a board with a +dimmable backlight would keep it from rotting. + +## What went well + +- Grilling before coding removed the two most expensive unknowns (whether "back" means close-the-app, + and whether a raise API had to exist). +- Pinning the baseline commit in a separate worktree with an in-worktree build directory made the + before/after numbers unambiguous. Note for future sessions: this repo's CMake includes + `../Buildscripts/device.cmake` by relative path, so the build directory must be a direct child of + the project directory - a truly external build directory does not configure. +- Two-axis review paid off: the spec axis found a real correctness bug (back closing the wrong app + after a raise) that a standards-only pass would have missed. diff --git a/docs/mobile/ios-gestures-review.md b/docs/mobile/ios-gestures-review.md new file mode 100644 index 000000000..28b3e3b50 --- /dev/null +++ b/docs/mobile/ios-gestures-review.md @@ -0,0 +1,95 @@ +# Code review: iOS-style gestures (feat/ios-gestures) + +Reviewed range: `7462d32...HEAD` (commits f92726b2, d117b591, 6c307e93, 9e726f31, 2a428bc9, plus the +fix commit that follows this document). +Spec: `docs/mobile/ios-gestures-spec.md`. Tickets: `docs/mobile/ios-gestures-tickets.md`. +Two axes, reviewed separately by independent reviewers: **Standards** (repo standards + +Fowler smell baseline) and **Spec** (does the code do what the spec asked). + +## Standards + +Hard violations of the repo's documented standards: **none**. `CODING_STYLE_C.md`, +`CODING_STYLE_CPP.md`, `CONTRIBUTING.md`, the doc-comment shape of the existing `SystemBars.h` / +`SystemMenu.h`, the file locations and the `enum class`/no-exceptions rules were all respected. The +touched files are clang-format clean against the repo's `.clang-format`. + +Judgement calls (smell baseline): + +1. **Duplicated Code** - `raise_on_main_dispatcher` + `raiseWindow` were verbatim copies in + `SystemGestures.cpp` and `SystemRecents.cpp`. +2. **Duplicated Code + Shotgun Surgery** - the backlight percentage mapping was copied from the + Display app into the quick panel, and the "find the display backlight" lookup with it. +3. **Duplicated Code** - two window-stack scan strategies: `findLauncherWindow` used a fixed + 16-entry array, `findAppInstance`/`collectEntries` used chunked reads. +4. **Middle Man** - `hideOverlay()` only delegated to `window_manager_overlay_hide()`. +5. **Primitive Obsession / repeated cast** - `reinterpret_cast(static_cast(...))` + and its inverse recurred five times, and `0` stood in for "no window" without a name. +6. **Doc nit** - one sentence in `app/manager.h` referred to "that case" with no antecedent; an + unused `` include in `SystemMenu.cpp`. + +## Spec + +Blockers: + +1. **(c) Back closed the wrong app after a raise.** The back gesture used the newest *active app + instance*, not the window manager's top window. After Home or a recents switch, the launcher is on + top while the newest instance is buried, so back would close a buried app. Spec: "The window stack + stays the single source of truth for 'which apps are open and in what order'." The same bug existed + in the toolbar's default close action, which the spec says back mirrors. +2. **(c) 0-1 backlights got a useless slider.** The check was `max > min`, so an on/off backlight + showed a 0-100 slider in which every position means "on". Spec: "a brightness slider when the + device has a dimmable backlight". + +Suggestions / partial requirements: + +3. **(a)** Home only scanned the first 16 windows, while the spec calls the stack unbounded. +4. **(c)** The pointer device's `LV_EVENT_PRESS_LOST` callback never fires (LVGL sends that event to + the active object), so a cancelled press could leave the gesture state armed. +5. **(a)** `app_manager_get_instance_app_id` was missing from the app module's exported symbol table, + so dynamically loaded apps could not call it. +6. **(a)** Ticket 09 (footprint + verification record) was still open. + +Scope creep: none. + +## Disposition + +| # | Axis | Finding | Decision | +|---|------|---------|----------| +| S1 | Standards | duplicated raise helper | Fixed: one `raiseWindow()` in the new shared system-window header, used by both. | +| S2 | Standards | duplicated backlight mapping | Fixed: `Tactility/Private/Tactility/Backlight.h` holds the display-backlight lookup, the dimmable test and the percentage mapping; the Display app and the quick panel both use it. | +| S3 | Standards | two scan strategies | Fixed: one chunked `forEachWindow()` walks the unbounded stack; both lookups use it. | +| S4 | Standards | middle man | Fixed: callers call the window manager directly. | +| S5 | Standards | repeated casts / magic 0 | Fixed: `toWindowContext()`/`fromWindowContext()` in the shared header, and `WINDOW_ID_NONE` in the window manager's public header. | +| S6 | Standards | doc nit, unused include | Fixed. | +| P1 | Spec | back closed the wrong app | Fixed: back closes the app of the **topmost window**; the toolbar's default close action was corrected the same way. | +| P2 | Spec | 0-1 backlight slider | Fixed: the shared `isDimmable()` excludes on/off ranges, so the slider only appears where it means something. | +| P3 | Spec | 16-window home scan | Fixed by the same `forEachWindow()`; the limit and its constant are gone. | +| P4 | Spec | `PRESS_LOST` never reaches the indev | Fixed differently: the callback is removed, and the hold timer plus the next press self-heal from `lv_indev_get_state()`, which covers cancelled presses whatever event they produce. | +| P5 | Spec | missing module symbol | Fixed: registered in the app module's symbol table. | +| P6 | Spec | ticket 09 open | Fixed: baseline measured in a worktree at `7462d32` with an out-of-repo build directory, values recorded in the tickets doc. | + +Rejected: none. + +## Measurements after the fixes + +`idf.py size`, device `es3c28p`, baseline `7462d32` vs `feat/ios-gestures`: + +| Metric | Before | After | Delta | +|---|---|---|---| +| `Tactility.bin` | 3,881,072 B | 3,885,008 B | **+3,936 B** | +| Flash `.text` (code) | 2,766,982 B | 2,770,866 B | +3,884 B | +| Flash `.rodata` | 961,872 B | 961,920 B | +48 B | +| Static DRAM (`.data` + `.bss`) | 64,975 B | 65,007 B | **+32 B** | +| DIRAM in use | 167,494 B (49.01%) | 167,526 B (49.02%) | +32 B | +| IRAM | 16,384 B (100%) | 16,384 B (100%) | 0 | + +The whole feature costs ~3.9 KB of flash and 32 bytes of static RAM, with no IRAM and no `.data` +growth. With `CONFIG_TT_SYSTEM_GESTURES` off, the gesture layer, the switcher and the panel's recents +entry compile out. + +## Verdict + +Both axes green after the fixes above. Build for `es3c28p` passes. The committed gesture geometry +test was compiled and run directly with doctest in this environment (3 test cases, 20 assertions, all +passing); the repository's full `build-tests` target could not run locally because of missing Python +packages - see the retro. diff --git a/docs/mobile/ios-gestures-spec.md b/docs/mobile/ios-gestures-spec.md new file mode 100644 index 000000000..59711c3b4 --- /dev/null +++ b/docs/mobile/ios-gestures-spec.md @@ -0,0 +1,204 @@ +# iOS-style system gestures for Touch Devices + +Status: implemented (branch `feat/ios-gestures`, device `es3c28p`, 240x320) + +## Problem Statement + +On a Tactility touch device, nothing brings the user back to the launcher without closing every open +app through its toolbar. Apps stay resident when another app is started, but they become invisible: +the window manager only keeps widgets for the topmost window, so a buried app can only surface again +when the app above it is closed. The user has no gesture to go home, no way to switch between running +apps, and no edge gesture to leave the current app. The quick panel that the statusbar opens today +shows toggles only: no clock, no Wi-Fi status, no brightness, and no way to reach the running apps. + +## Solution + +Three edge gestures from the screen edges, plus a recents switcher: + +- **Swipe up from the bottom edge** raises the launcher, keeping the apps above it in the window stack. +- **Swipe up from the bottom edge and hold** opens the recents switcher: the window stack as + horizontal cards, most recent first, at most five. Tapping a card raises that app; swiping a card up + closes that app. +- **Swipe right from the left edge** closes the topmost app, which reveals the window below it (the + same effect as the toolbar close button). It does nothing on the launcher. + +The quick panel (swipe down from the statusbar, as before) becomes a dimmed sheet that hangs from the +top edge and shows the clock, the Wi-Fi status next to its toggle, a brightness slider when the device +has a dimmable backlight, and a recents button beside the existing toggles. + +Gestures only take drags that **start** in a 20 px strip along an edge, and only when the swipe travels +at least 40 px in the expected direction. Everywhere else, apps keep their own swipes (launcher page +swipes, list scrolling, carousels). The widget under the finger never also receives the click. + +## User Stories + +1. As a touch-device user, I want to swipe up from the bottom edge to go home, so that I can reach the + launcher without closing the app I was using. +2. As a touch-device user, I want the app I left to keep running while I am on the launcher, so that + returning to it does not lose my place. +3. As a touch-device user, I want to swipe up and keep my finger down to see the running apps, so that + I can choose between them instead of guessing. +4. As a touch-device user, I want the recents switcher to show the most recent app first, so that the + app I just left is the first card. +5. As a touch-device user, I want the recents switcher to show at most five cards, so that it stays + readable on a 240x320 screen. +6. As a touch-device user, I want to tap a recents card to switch to that app, so that switching keeps + the app's state. +7. As a touch-device user, I want to swipe a recents card up to close that app, so that I can free + memory without hunting for the app. +8. As a touch-device user, I want each recents card to show the app's icon and name, so that I can + recognise it at a glance. +9. As a touch-device user, I want the launcher to appear as a card in the switcher, so that I can go + home from the switcher with the same gesture. +10. As a touch-device user, I want to swipe right from the left edge to leave the current app, so that I + do not have to find its close button. +11. As a touch-device user, I want the back gesture to do nothing on the launcher, so that I cannot + close my home screen by accident. +12. As a touch-device user, I want a swipe that starts in the middle of the screen to keep going to the + app, so that paging and scrolling never fight the system gestures. +13. As a touch-device user, I want a drag that starts at an edge to not also click the widget under my + finger, so that a gesture never triggers a button by accident. +14. As a touch-device user, I want the quick panel to show the current time, so that I do not have to + close it to check the clock. +15. As a touch-device user, I want the quick panel to show whether Wi-Fi is on and which network it is + on, so that I can tell the state without opening the Wi-Fi app. +16. As a touch-device user, I want to change the brightness from the quick panel, so that I can dim the + screen without going to the Display settings app. +17. As a touch-device user, I want the brightness I chose to be remembered, so that it survives a + reboot. +18. As a touch-device user, I want a recents button in the quick panel, so that I can reach the switcher + without remembering the hold gesture. +19. As a touch-device user, I want the quick panel to slide in from the top edge, so that it reads as + the thing my swipe pulled down. +20. As a touch-device user, I want to close the quick panel by swiping up or tapping beside it, so that + dismissing it needs no target. +21. As a user of a device whose left edge holds the statusbar strip (side layout), I want the left-edge + back gesture disabled, so that the strip keeps working as before. +22. As a user of a keypad- or encoder-only device, I want the firmware to behave exactly as before, so + that no gesture code runs where there is no touch screen. +23. As a user of a keypad- or encoder-only device that does show the quick panel, I want to reach and + close the recents switcher with the keys, so that I am not trapped in a screen I cannot dismiss. +24. As a device integrator, I want to switch the whole gesture feature off at build time, so that the + code is not in the firmware on devices that do not need it. +25. As a device integrator, I want the gestures to cost no RAM or CPU while no finger is on the screen, + so that the feature does not slow down the rest of the system. +26. As a device integrator, I want the feature to add no new dependencies, so that the firmware size + stays predictable. +27. As a developer, I want the gesture geometry (edge strips, thresholds, direction rules) to be plain + arithmetic that can be unit tested without a display, so that the edge cases stay covered. +28. As a developer, I want raising a window to be a window-manager operation, so that apps do not need + to know about the switcher or the home gesture. + +## Implementation Decisions + +### Window stack + +- The window manager gains two read/raise operations: enumerate the window stack (bounded copy, with + an offset so a caller can read only the most recent entries) and raise a window by id. +- Raising moves the window's record to the top of the stack and rebuilds its widgets through the + `create_widgets` callback it registered. The raised app is not restarted, so the state it keeps in + its own Context survives. The widgets of the window that was topmost are deleted first, exactly as a + new window would do to it. +- Raising is serialized with window creation and removal (the same lifecycle mutex), because it moves + records while widgets are built. It therefore must not be called from an LVGL event callback, where + the LVGL lock is already held: a concurrent create/remove could be holding the lifecycle mutex while + waiting for that lock. Callers defer it to the main dispatcher. +- The window stack stays the single source of truth for "which apps are open and in what order". No new + IPC, no per-app bookkeeping. + +### App identity + +- The app manager gains a lookup from any app instance to its manifest id, mirroring the existing + topmost-instance lookup. Cards and the launcher search are built from it; names and icons come from + the manifest. +- App icons move from two per-launcher-mode tables into one shared lookup, so the switcher shows the + same icon the launcher does instead of a third table. The launcher gains an icon for itself (used + only by the switcher, since it stays hidden from the app list). + +### Gestures + +- Gestures are captured on the pointer devices themselves (press/release/press-lost), not on widgets, + so they work over any app, including scrollable content. +- Only drags that start in a 20 px edge strip belong to the system; a 40 px travel in the expected + direction makes it a swipe. Sideways ambiguity resolves to the app. +- The bottom edge wins over the left edge in the corner, so going home always works there. +- The top edge is not handled by this layer: the statusbar already owns swipe-down (and the side-layout + strip owns the top edge when the statusbar is on the left). +- The left edge is disabled when the system bars use the side layout, because the statusbar strip owns + that edge there. +- The release is consumed when a gesture fires, so the widget under the finger does not also get a + click. +- The hold variant uses a timer that is created when a bottom-edge press starts and deleted when it + ends: no timer exists while the screen is untouched. The timer polls at 50 ms and only for the + duration of one press. +- A shown overlay (quick panel, recents) disables the system gestures; each overlay has its own + dismiss gestures. +- The geometry (edge strip choice, swipe direction and distance) is a pure function, unit tested. + +### Recents switcher + +- It is a window-manager overlay, like the quick panel: dimmed backdrop, cards, dismissed by swiping up + on the backdrop or tapping outside. +- It lists the window stack topmost first, at most five entries, each with the app's shared icon and + manifest name. +- Tapping a card raises that window (deferred to the main dispatcher) and hides the switcher. Swiping a + card up closes that app the same way the toolbar close does (an app close event, not a forced stop), + then hides the switcher. +- With a keypad or encoder present, a focused close button is added so the overlay can always be + dismissed. + +### Quick panel + +- The existing quick panel content is reused and extended; it is not rewritten. +- It becomes a sheet aligned to the top edge and full width, sliding in over 180 ms with the theme's + animations setting respected. No out-animation (the overlay deletes its widgets). +- It gains: a clock label (and a placeholder before the clock is set), a Wi-Fi status label next to the + toggles ("off", "on", or the network name), a brightness slider when the display's backlight can be + dimmed, and a recents tile. +- Brightness is applied immediately while dragging but persisted once, when the panel closes, on the + main dispatcher, so dragging does not write a settings file per step. +- The panel is not shown when the gesture layer is compiled out. + +### Configuration and footprint + +- A Kconfig option (`TT_SYSTEM_GESTURES`, default on) guards the gesture layer, the switcher and the + panel's recents entry. Turning it off compiles the code out. +- At runtime the layer also requires a pointer input device; keypad/encoder-only devices get no + callbacks at all and behave exactly as before. +- No new dependencies, no STL containers on the gesture path, no heap allocation per gesture: the edge + scan uses small fixed arrays sized by the number of entries it shows. +- Nothing polls while idle. Brightness UI updates only what changed; the panel animates only its own + translation. + +## Testing Decisions + +- Good tests cover externally visible behaviour: which drag belongs to the system and which belongs to + the app. They do not test LVGL widget internals or event plumbing. +- The gesture geometry is tested as a pure function (edge strip, threshold, diagonal rejection), next to + the existing system-bars layout tests, using the same doctest suite. +- Raising a window, the switcher and the panel are verified on a real build of the target device; no + test harness exists for LVGL interaction, and the device is the only place where the window stack, + timers and input devices are real. +- Verification for this feature is a clean `idf.py build` for `es3c28p` plus `idf.py size`, compared + against the commit the work started from. + +## Out of Scope + +- Live thumbnails of the app windows: only the topmost window has widgets, and capturing the others + would need a snapshot buffer per app (far more RAM than a 240x320 device should spend here). +- An in-app navigation stack: "back" closes the topmost app, exactly like the toolbar close. Apps do + not gain history. +- Gestures on non-touch devices (mouse, trackball, keypad, encoder) and gesture equivalents for them. +- Making the launcher auto-start or starting any app from the system. +- Backlight support on boards whose devicetree disables it (including `es3c28p` today): the brightness + row simply does not appear there. +- Enabling backlight hardware, changing any devicetree, or adding a brightness setting of its own. + +## Further Notes + +- Because apps are resident and the window stack is unbounded, the switcher shows only the five most + recent windows; older ones remain reachable by closing what is above them, as before. +- The launcher rebuilds its grid from its Context when it is raised, so its page and mode survive a + round trip through another app. +- The side layout (wide landscape screens) keeps the statusbar strip on the left; the back gesture is + deliberately absent there rather than competing with the strip. diff --git a/docs/mobile/ios-gestures-tickets.md b/docs/mobile/ios-gestures-tickets.md new file mode 100644 index 000000000..c54c9d09a --- /dev/null +++ b/docs/mobile/ios-gestures-tickets.md @@ -0,0 +1,210 @@ +# Tickets: iOS-style system gestures for touch devices + +Spec: `docs/mobile/ios-gestures-spec.md` +Integration branch: `feat/ios-gestures` (origin: `Heldinhow/tactility-mobile`) +Definition of done for every ticket: `source ~/esp/esp-idf-v6.1/export.sh && python3 device.py es3c28p && idf.py build` exits 0. + +Status vocabulary: `done` (merged on the branch, build green), `in progress`, `blocked`. + +Vertical slices: each ticket below is complete through every layer it touches (data source → API → UI) +and verifiable on its own, either by a green build of the device or by the unit tests. + +--- + +## 01: The window manager can list and raise windows + +**What to build:** Any system UI can ask which windows are open, bottom-most first, and can bring one +of them to the front without restarting its app. Raising rebuilds the raised window's widgets from the +app's own Context; the app that was on top keeps running with its window buried. + +**Blocked by:** None (can start immediately). + +**Status:** done + +- [x] Listing returns the stack order and copies into a caller-provided buffer, with an offset so a + caller can read only the most recent entries. +- [x] Listing allocates nothing and takes no LVGL lock, so it is callable from an LVGL event. +- [x] Raising a buried window moves it to the top, deletes the previous top window's widgets, and + rebuilds the raised window's widgets. +- [x] Raising the topmost window is a no-op; raising an unknown id fails without side effects. +- [x] Raising is serialized with window create/remove and is documented as not callable from an LVGL + event callback. +- [x] Build green for `es3c28p`. + +## 02: Every app instance resolves to its manifest, and icons come from one table + +**What to build:** Given any app instance id (not just the topmost one), the app manager reports its +manifest id. The launcher's apps list, its settings list and the new switcher show the same icon for an +app, from one shared lookup. + +**Blocked by:** None (can start immediately). + +**Status:** done + +- [x] Lookup by instance returns the manifest id, or a not-found error for an unknown instance. +- [x] The two launcher mode tables are replaced by one shared lookup; visible icons are unchanged. +- [x] The launcher has an icon for itself, used by the switcher only. +- [x] Build green for `es3c28p`. + +## 03: Edge swipes are captured, and the left edge closes the topmost app + +**What to build:** A touch drag that starts in a 20 px screen-edge strip belongs to the system; one that +starts anywhere else keeps going to the app. A rightward swipe of at least 40 px from the left edge +closes the topmost app (an app close event, like the toolbar button), and does nothing on the launcher. +The widget under the finger never also receives the click. Gestures are ignored while an overlay is +shown, and on devices without a pointer device nothing is installed. + +**Blocked by:** 01, 02. + +**Status:** done + +- [x] Only presses that start in an edge strip arm a gesture; taps and drags elsewhere are untouched. +- [x] A left-edge drag that travels sideways enough and is mostly horizontal closes the topmost app. +- [x] The same drag on the launcher does nothing. +- [x] The release is consumed so no click reaches the widget under the finger. +- [x] The left edge is not handled when the system bars use the side layout. +- [x] Build green for `es3c28p`. + +## 04: Swipe up from the bottom edge goes home + +**What to build:** An upward swipe of at least 40 px starting in the bottom edge strip raises the +launcher's window, leaving the apps above it in the stack. When the launcher is already on top, nothing +happens. + +**Blocked by:** 03. + +**Status:** done + +- [x] The launcher's window is found by app id, not by assuming it is the bottom-most window. +- [x] The raise is deferred off the LVGL task and does not block the gesture handler. +- [x] An upward drag that is mostly sideways does not go home. +- [x] Build green for `es3c28p`. + +## 05: The recents switcher, from the hold gesture and from the panel + +**What to build:** A bottom-edge swipe that stays held for 500 ms opens a switcher overlay: the window +stack as horizontal cards, most recent first, at most five, each showing the app's icon and name. +Tapping a card raises that app; swiping a card up closes that app; swiping the backdrop up or tapping +outside closes the switcher. With a keypad or encoder present, a focused close button is available. + +**Blocked by:** 04. + +**Status:** done + +- [x] The switcher lists at most five windows, topmost first, with icon and name. +- [x] Tapping a card raises the app's window (state preserved, no restart) and hides the switcher. +- [x] Swiping a card up closes that app and hides the switcher. +- [x] The hold timer exists only while a bottom-edge press is down; the release does not also go home. +- [x] The switcher is not opened when no window exists. +- [x] Build green for `es3c28p`. + +## 06: The quick panel hangs from the top edge with clock, Wi-Fi status, brightness and recents + +**What to build:** The existing quick panel content stays, but the panel becomes a full-width sheet at +the top edge with a pull-down animation. It gains the clock, the Wi-Fi status next to the toggles, a +brightness slider when the display's backlight is dimmable (persisted once, when the panel closes), and +a recents tile. Swiping up or tapping outside still closes it. + +**Blocked by:** 02, 05. + +**Status:** done + +- [x] The panel aligns to the top edge, full width, and slides in over ~180 ms, respecting the theme's + animation setting. +- [x] A clock label shows the current time, with a placeholder before the clock is set. +- [x] The Wi-Fi status reads off / on / connected network name. +- [x] The brightness slider appears only when a dimmable backlight exists, applies immediately and is + saved once when the panel closes. +- [x] The recents tile opens the switcher. +- [x] The keypad/encoder close button still works. +- [x] Build green for `es3c28p`. + +## 07: The feature can be switched off at build time, and non-touch devices are untouched + +**What to build:** A Kconfig option (default on) removes the gesture layer, the switcher and the panel's +recents entry from the firmware when off. With it on but no pointer device, no callbacks are installed. + +**Blocked by:** 03. + +**Status:** done + +- [x] The option exists in the device configuration and defaults to on. +- [x] Turning it off compiles the feature out (the gesture layer and switcher reduce to no-op stubs). +- [x] Without a pointer input device, attaching does nothing. +- [x] Build green for `es3c28p` with the option on. + +## 08: Gesture geometry is unit tested + +**What to build:** The pure rules behind the gestures are tested without a display: which edge a press +belongs to, what counts as an upward swipe, what counts as a rightward swipe, thresholds, and diagonal +ambiguity. + +**Blocked by:** 03. + +**Status:** done + +- [x] Edge strip boundaries and the bottom-corner precedence are covered. +- [x] Threshold boundaries (40 px counts, 39 does not) are covered for both directions. +- [x] Equal-axis diagonals and the wrong direction are rejected. +- [x] The tests sit with the existing system-bars layout tests, in the same suite, and pass + when that translation unit is compiled and run with doctest (20 assertions). + +## 09: Footprint measured against the starting commit, and the work published + +**What to build:** The feature's cost is measured on the target device build: application image size and +static DRAM/IRAM from `idf.py size`, before (commit `7462d32`, built in a worktree outside the repo) and +after. The branch carries the spec, these tickets, the review findings, the retro, and a clear commit +history; it is pushed to `origin`. + +**Blocked by:** 01–08. + +**Status:** done + +- [x] Baseline built at `7462d32` in a separate worktree (`/tmp/tm-baseline`, submodules and managed + components linked from the main checkout). The build directory has to live inside the project + directory for this repo's CMake (`Tactility/CMakeLists.txt` includes `../Buildscripts/ + device.cmake` by relative path), so it was `/tmp/tm-baseline/build` - outside this repository, + inside the throwaway worktree. +- [x] `idf.py size` recorded for before and after: + + Metric Before After Delta + Tactility.bin 3,881,072 B 3,885,008 B +3,936 B + Flash .text (code) 2,766,982 B 2,770,866 B +3,884 B + Flash .rodata 961,872 B 961,920 B +48 B + Static DRAM (.data+.bss) 64,975 B 65,007 B +32 B + DIRAM in use 167,494 B 167,526 B +32 B + IRAM 16,384 B 16,384 B 0 + +- [x] `idf.py build` green for `es3c28p` on the final tree. +- [x] Spec, tickets, review and retro committed on `feat/ios-gestures` and pushed to `origin`. + +## 10: Apply the code review's findings + +**What to build:** The accepted findings from `docs/mobile/ios-gestures-review.md` are applied: back +acts on the topmost window rather than the newest app instance (and the toolbar's default close with +it), the brightness slider only appears for a dimmable backlight, the home scan walks the whole stack, +a cancelled press cannot leave the gesture state armed, the new app-manager lookup is exported, and +the duplicated helpers (raise, backlight mapping, stack scanning, id casts) are single-sourced. + +**Blocked by:** 01–09. + +**Status:** done + +- [x] Back closes the app of the topmost window; the launcher is never closed. +- [x] On/off backlights do not get a brightness slider. +- [x] The launcher lookup is not limited to a fixed number of windows. +- [x] A press whose release never arrives is cleared by the hold timer or the next press. +- [x] The window-manager and app-manager additions are in the module symbol tables. +- [x] One shared backlight helper serves the Display app and the quick panel. +- [x] One shared system-window helper serves the gestures and the switcher. +- [x] Build green for `es3c28p`; clang-format clean against the repo's `.clang-format`. + +--- + +## Notes on verification + +- There is no automated UI harness in this repository, and this work is explicitly not flashed (no + hardware in the loop for this branch). "Verified" therefore means: the pure logic is unit tested and + the device firmware builds and links with the feature enabled. On-device feel (timing, thresholds, + animation) is deliberately left for the next hardware session, and the thresholds are named + constants so tuning stays a one-line change.