Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,18 @@ jobs:
- name: Run Unit Tests
run: |
ctest --test-dir build

# Step 7: Same again with the optional runtime-rotation feature enabled,
# which links both generated layouts. X11 is not LGFX, so this covers
# compiling, linking and the state tests -- not the panel rotation itself.
- name: Configure CMake (runtime rotation)
run: |
cmake -S . -B build-rotation -G Ninja -DMUI_RUNTIME_ROTATION=ON

- name: Build Project (runtime rotation)
run: |
cmake --build build-rotation

- name: Run Unit Tests (runtime rotation)
run: |
ctest --test-dir build-rotation
25 changes: 23 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ endif()
option(ENABLE_DOCTESTS "Include tests in the library. Setting this to OFF will remove all doctest related code.
Tests in tests/*.cpp will still be enabled." ${MAIN_PROJECT})
option(ENABLE_DEBUG_LOG "Enable debug log" OFF)
option(MUI_RUNTIME_ROTATION "Link both perpendicular UI layouts and select the screen rotation at runtime" OFF)

set_property(GLOBAL PROPERTY USE_FOLDERS ON)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON) # with newer cmake versions put all find_package in global scope
Expand All @@ -24,14 +25,18 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/bin")
set(GENERATED_VIEW ${VIEW} "ui_320x240")
if(NOT VIEW)
set(VIEW "ui_320x240")
endif()
set(GENERATED_VIEW ${VIEW})

message(STATUS "Using C++${CMAKE_CXX_STANDARD}")
message(STATUS "Using Generated View: " ${GENERATED_VIEW})

add_compile_definitions(ARCH_PORTDUINO)
add_compile_definitions(ARDUINO)
add_compile_definitions(VIEW_320x240)
string(REGEX REPLACE "^ui_" "VIEW_" GENERATED_VIEW_MACRO ${GENERATED_VIEW})
add_compile_definitions(${GENERATED_VIEW_MACRO})
add_compile_definitions(USE_X11=1)

include(FetchContent)
Expand All @@ -48,9 +53,18 @@ find_package(CURL REQUIRED)
file(GLOB_RECURSE sources source/* generated/* portduino/* locale/* generated/${GENERATED_VIEW}/*)
file(GLOB_RECURSE sources_test tests/*.cpp)

if(MUI_RUNTIME_ROTATION)
include(MuiRuntimeRotation)
mui_runtime_rotation_sources(sources)
endif()

add_library(DeviceUI ${sources})
target_link_libraries(DeviceUI PRIVATE lvgl::lvgl LovyanGFX Portduino Protobufs CURL::libcurl)

if(MUI_RUNTIME_ROTATION)
mui_runtime_rotation_configure(DeviceUI)
endif()

# Handle Windows Static linking dependencies if applicable
if(WIN32)
target_compile_definitions(DeviceUI PRIVATE CURL_STATICLIB)
Expand Down Expand Up @@ -91,4 +105,11 @@ if(ENABLE_DOCTESTS)
)
set_target_properties(tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
add_test(NAME tests COMMAND tests)

if(MUI_RUNTIME_ROTATION)
target_include_directories(tests PRIVATE ${MUI_GLUE_DIR})
add_test(NAME dual_ui_glue
COMMAND ${Python_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_dual_ui.py
--check ${MUI_PRIMARY_VIEW})
endif()
endif()
103 changes: 103 additions & 0 deletions cmake/MuiRuntimeRotation.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Dual-layout build support for MUI_RUNTIME_ROTATION.
#
# Links both perpendicular generated layouts into one binary so the screen
# rotation becomes a runtime setting. The primary tree (this build's VIEW_*)
# keeps its symbols and supplies every shared asset; the secondary tree is
# compiled with its colliding globals renamed via a generated forced include.
#
# mui_runtime_rotation_sources(<sources_var>) rewrites the caller's source list.
# mui_runtime_rotation_configure(<target>) applies the target settings.

set(MUI_DUAL_VIEWS ui_320x240 ui_240x320)
set(MUI_GLUE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/tools/generated/dual)
set(MUI_GENERATOR ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_dual_ui.py)

function(mui_runtime_rotation_sources sources_var)
list(GET MUI_DUAL_VIEWS 0 _first)
list(GET MUI_DUAL_VIEWS 1 _second)
if(GENERATED_VIEW STREQUAL _first)
set(_primary ${_first})
set(_secondary ${_second})
elseif(GENERATED_VIEW STREQUAL _second)
set(_primary ${_second})
set(_secondary ${_first})
else()
message(FATAL_ERROR
"MUI_RUNTIME_ROTATION supports only ${_first} and ${_second}, not '${GENERATED_VIEW}': "
"it links the two perpendicular layouts and no other generated tree has a counterpart.")
endif()

find_package(Python COMPONENTS Interpreter REQUIRED)
execute_process(COMMAND ${Python_EXECUTABLE} ${MUI_GENERATOR} --check ${_primary}
RESULT_VARIABLE _rc OUTPUT_VARIABLE _out ERROR_VARIABLE _err)
if(NOT _rc EQUAL 0)
message(FATAL_ERROR "MUI_RUNTIME_ROTATION: ${_out}${_err}")
endif()

# The build reads the source inventory from the manifest so it cannot drift
# from the generator.
file(STRINGS ${MUI_GLUE_DIR}/manifest.txt _manifest)
set(_secondary_names "")
set(_glue_names "")
foreach(_line IN LISTS _manifest)
if(_line MATCHES "^core_source (.+)$")
list(APPEND _secondary_names ${CMAKE_MATCH_1})
elseif(_line MATCHES "^secondary_asset ${_secondary} (.+)$")
# only the secondary tree's unique assets need compiling
list(APPEND _secondary_names ${CMAKE_MATCH_1})
elseif(_line MATCHES "^glue_source (.+)$")
list(APPEND _glue_names ${CMAKE_MATCH_1})
endif()
endforeach()

# Drop every generated tree from the catch-all glob, then add each tree
# exactly once. Without this both trees enter the target and their
# identically named globals collide.
set(_sources ${${sources_var}})
list(FILTER _sources EXCLUDE REGEX "/generated/ui_[0-9]+x[0-9]+/")

file(GLOB_RECURSE _primary_sources ${CMAKE_CURRENT_SOURCE_DIR}/generated/${_primary}/*)
list(APPEND _sources ${_primary_sources})

set(_secondary_sources "")
foreach(_name IN LISTS _secondary_names)
set(_path ${CMAKE_CURRENT_SOURCE_DIR}/generated/${_secondary}/${_name})
if(EXISTS ${_path})
list(APPEND _secondary_sources ${_path})
endif()
endforeach()

# from the manifest, not a glob: a stale .c left in the directory must not
# silently enter the build
set(_glue_sources "")
foreach(_name IN LISTS _glue_names)
list(APPEND _glue_sources ${MUI_GLUE_DIR}/${_name})
endforeach()
list(APPEND _sources ${_secondary_sources} ${_glue_sources})

# The secondary tree and the secondary-side bridge must see the renames and
# the secondary headers; the primary-side bridge must see the primary tree
# untouched.
set(_rename_flags
-include ${MUI_GLUE_DIR}/ui_secondary_rename.h
-I${CMAKE_CURRENT_SOURCE_DIR}/generated/${_secondary})
set_source_files_properties(
${_secondary_sources} ${MUI_GLUE_DIR}/bridge_secondary.c
DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
PROPERTIES COMPILE_OPTIONS "${_rename_flags}")

# C++ sources referencing generated styles dispatch them to the active tree.
set_source_files_properties(
${CMAKE_CURRENT_SOURCE_DIR}/source/graphics/TFT/Themes.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/graphics/TFT/TFTView_320x240.cpp
DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
PROPERTIES COMPILE_OPTIONS "-include;${MUI_GLUE_DIR}/style_routing.h")
Comment on lines +84 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the declared minimum CMake version.
set -euo pipefail
rg -n 'cmake_minimum_required' --glob '*.txt' --glob '*.cmake'

Repository: meshtastic/device-ui

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "CMake files:"
git ls-files | rg '(^|/)(CMakeLists\.txt|.*\.cmake)$' || true

echo
echo "cmake_minimum_required occurrences:"
rg -n --fixed-strings 'cmake_minimum_required' . --glob '*.txt' --glob '*.cmake' || true

echo
echo "Directory option usages:"
rg -n 'set_source_files_properties' --glob '*.cmake' --glob '*.txt' . || true

echo
echo "Relevant file excerpt:"
if [ -f cmake/MuiRuntimeRotation.cmake ]; then
  sed -n '1,130p' cmake/MuiRuntimeRotation.cmake | cat -n
fi

Repository: meshtastic/device-ui

Length of output: 5946


🌐 Web query:

CMake set_source_files_properties DIRECTORY option version documentation

💡 Result:

The DIRECTORY option for the set_source_files_properties command in CMake was introduced in version 3.18 [1][2]. It allows you to set source file properties in specific directory scopes beyond the current CMakeLists.txt file [1][3]. By default, source file properties are only visible to targets defined within the same directory scope [1][2]. The DIRECTORY option overrides this behavior by applying the specified properties to the source files within each of the provided directory scopes [1][3]. Key requirements and details for using the DIRECTORY option include: 1. Prerequisite Knowledge: CMake must already be aware of the specified directories [1][3]. This means they must have been added previously via the add_subdirectory command or be the top-level source directory [1][2]. 2. Path Handling: Relative paths provided to the DIRECTORY option are interpreted as relative to the current source directory [1][3]. 3. Alternative Scope: The command also supports a TARGET_DIRECTORY option, which allows setting properties in the directory scopes where specific targets were created [1][2]. You can retrieve these properties using the get_source_file_property command [1][3]. Note that the GENERATED property is an exception to these scoping rules and may be globally visible [1][4].

Citations:


Require CMake 3.18 for the CMakeLists.txt minimum.

CMakeLists.txt declares cmake_minimum_required(VERSION 3.15), but cmake/MuiRuntimeRotation.cmake uses set_source_files_properties(... DIRECTORY ...) from lines 84-94. That call is invalid before CMake 3.18, so older compatible CMake releases fail at configure time and the rotation rename or style-routing flags are not applied.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmake/MuiRuntimeRotation.cmake` around lines 84 - 94, Update the project’s
cmake_minimum_required declaration in CMakeLists.txt from version 3.15 to 3.18
so the DIRECTORY option used by set_source_files_properties in
MuiRuntimeRotation.cmake is supported. Preserve the existing minimum-version
configuration otherwise.


set(MUI_PRIMARY_VIEW ${_primary} PARENT_SCOPE)
set(${sources_var} ${_sources} PARENT_SCOPE)
endfunction()

function(mui_runtime_rotation_configure target)
target_compile_definitions(${target} PUBLIC MUI_RUNTIME_ROTATION)
target_include_directories(${target} PUBLIC ${MUI_GLUE_DIR})
endfunction()
90 changes: 86 additions & 4 deletions extra_script.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,106 @@
# See https://docs.platformio.org/en/latest/manifests/library-json/fields/build/extrascript.html
Import("env")
from os.path import join, realpath
import os
import subprocess
from os.path import basename, join, realpath

# Base srcFilter. Cannot be set in library.json.
src_filter = [
"+<resources>",
"+<locale>",
"+<source>"
]

DUAL_UI_VIEWS = ("ui_320x240", "ui_240x320")


def define_name(item):
# CPPDEFINES entries are either "NAME" or a ("NAME", value) pair
if isinstance(item, str):
return item
if isinstance(item, (list, tuple)) and item:
return str(item[0])
return ""


view = None
runtime_rotation = False
for item in env.get("CPPDEFINES", []):
name = define_name(item)
# Add generated view directory to include path dependending on VIEW_* macro
if isinstance(item,str) and item.startswith("VIEW_"):
view = f"ui_{item[5:]}".lower() # Ex value: "ui_320x240"
if name.startswith("VIEW_"):
view = f"ui_{name[5:]}".lower() # Ex value: "ui_320x240"
env.Append(CPPPATH=[realpath(join("generated", view))])
src_filter.append(f"+<generated/{view}>")
elif name == "MUI_RUNTIME_ROTATION":
runtime_rotation = True
# Add portduino directory to include path dependending on ARCH_PORTDUINO macro
elif item == "ARCH_PORTDUINO":
elif name == "ARCH_PORTDUINO":
env.Append(CPPPATH=[realpath("portduino")])
src_filter.append("+<portduino>")

if runtime_rotation:
# Link both perpendicular layouts so the rotation becomes a runtime setting.
# The primary tree (this build's VIEW_*) supplies every shared asset; the
# secondary tree is compiled with its colliding globals renamed through a
# generated forced include.
if view not in DUAL_UI_VIEWS:
raise Exception("MUI_RUNTIME_ROTATION supports only %s, not %r"
% (" and ".join(DUAL_UI_VIEWS), view))
secondary = DUAL_UI_VIEWS[1] if view == DUAL_UI_VIEWS[0] else DUAL_UI_VIEWS[0]

lib_root = realpath(".")
generated = join(lib_root, "tools", "generated", "dual")
generator = join(lib_root, "tools", "gen_dual_ui.py")

# Fails the build when the committed glue no longer matches the UI trees.
check = subprocess.run([env.subst("$PYTHONEXE"), generator, "--check", view],
capture_output=True, text=True)
if check.returncode != 0:
raise Exception("MUI_RUNTIME_ROTATION: %s%s" % (check.stdout, check.stderr))

core_sources, secondary_assets, glue_sources = [], [], []
with open(join(generated, "manifest.txt")) as f:
for line in f:
key, _, value = line.strip().partition(" ")
if key == "core_source":
core_sources.append(value)
elif key == "glue_source":
glue_sources.append(value)
elif key == "secondary_asset":
# "secondary_asset <view> <file>": only when <view> is the
# secondary tree does its unique asset have to be compiled
view_name, _, asset = value.partition(" ")
if view_name == secondary:
secondary_assets.append(asset)

for src_name in core_sources + secondary_assets:
if os.path.exists(join(lib_root, "generated", secondary, src_name)):
src_filter.append(f"+<generated/{secondary}/{src_name}>")
# listed rather than globbed, so a stale .c cannot enter the build
for src_name in glue_sources:
src_filter.append(f"+<tools/generated/dual/{src_name}>")
env.Append(CPPPATH=[generated])

rename_flags = ["-include", join(generated, "ui_secondary_rename.h"),
"-I", realpath(join("generated", secondary))]
routing_flags = ["-include", join(generated, "style_routing.h")]
STYLE_ROUTED_CXX = ("Themes.cpp", "TFTView_320x240.cpp")

def apply_dual_ui_flags(env, node):
path = realpath(node.srcnode().get_abspath())
name = basename(path)
secondary_tu = os.sep + secondary + os.sep in path and name not in secondary_assets
# the secondary-side bridge needs the secondary screens.h and the
# renames; the primary-side bridge must see the primary tree untouched
if secondary_tu or name == "bridge_secondary.c":
return env.Object(node, CPPFLAGS=env.get("CPPFLAGS", []) + rename_flags)
if name in STYLE_ROUTED_CXX:
return env.Object(node, CXXFLAGS=env.get("CXXFLAGS", []) + routing_flags)
return node

env.AddBuildMiddleware(apply_dual_ui_flags, "*")

# Only `Replace` is supported for SRC_FILTER, not `Append` or `Prepend`
env.Replace(SRC_FILTER=src_filter)

Expand Down
69 changes: 69 additions & 0 deletions include/graphics/ScreenRotation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#pragma once

#ifdef MUI_RUNTIME_ROTATION

// Only these two layouts are perpendicular counterparts of each other.
#if !defined(VIEW_320x240) && !defined(VIEW_240x320)
#error "MUI_RUNTIME_ROTATION requires VIEW_320x240 or VIEW_240x320"
#endif

// Those drivers apply a fixed rotation to touch coordinates and would
// desynchronize from a rotated panel.
#ifdef CUSTOM_TOUCH_DRIVER
#error "MUI_RUNTIME_ROTATION is not supported with CUSTOM_TOUCH_DRIVER"
#endif

#include <cstdint>

/**
* Runtime screen rotation for builds that link both generated UI layouts.
*
* The stored value is a quarter-turn count applied on top of the board's
* compile-time LGFX_ROTATION offset, so value 0 reproduces the board's default.
* Even values keep the compile-time VIEW_* layout, odd values select the
* perpendicular one. Applied at boot only: an LVGL layout tree cannot be
* rebuilt in place once the view has cached its object pointers.
*/
class ScreenRotation
{
public:
enum Value : uint8_t {
Rotation0 = 0,
Rotation90 = 1,
Rotation180 = 2,
Rotation270 = 3,
};

/// Supplied by the integrator before the display is created. Out-of-range
/// values fall back to the build default. Ignored after the first call.
static void setLoaded(uint8_t raw);
static void load(void);
static Value get(void) { return current; }

/// Odd quarter turns swap the aspect, so they need the perpendicular tree.
static bool usesSecondaryTree(Value v) { return ((uint8_t)v & 1) != 0; }

/// Nominal size of the layout, for seeding DisplayDriver. NOT the panel
/// resolution -- the driver reads that from the rotated panel.
static uint16_t width(Value v);
static uint16_t height(Value v);

/// Value passed to setRotation(); LovyanGFX composes it with the board's
/// offset_rotation. The quarter turns run opposite to the panel's own
/// numbering so that a rotation reads upright to the user: hardware-checked
/// on a 320x240 panel with offset_rotation 3, where upright portrait is
/// panel rotation 2, i.e. one step back from upright landscape, not one
/// step on. Parity is preserved, so tree selection is unaffected.
static uint8_t panelRotation(Value v) { return (uint8_t)((4 - (uint8_t)v) & 3); }

static bool usesSecondaryTree(void) { return usesSecondaryTree(current); }
static uint16_t width(void) { return width(current); }
static uint16_t height(void) { return height(current); }
static uint8_t panelRotation(void) { return panelRotation(current); }

private:
static Value current;
static bool loaded;
};

#endif // MUI_RUNTIME_ROTATION
17 changes: 16 additions & 1 deletion include/graphics/driver/LGFXDriver.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#pragma once

#include "LovyanGFX.h"
#ifdef MUI_RUNTIME_ROTATION
#include "graphics/ScreenRotation.h"
#endif
#include "graphics/driver/DisplayDriverConfig.h"
#include "graphics/driver/TFTDriver.h"
#include "input/InputDriver.h"
Expand Down Expand Up @@ -321,7 +324,19 @@ template <class LGFX> void LGFXDriver<LGFX>::init(DeviceGUI *gui)
lv_display_add_event_cb(this->display, rounder_cb, LV_EVENT_INVALIDATE_AREA, this->display);
#endif

#if defined(DISPLAY_SET_RESOLUTION)
#if defined(MUI_RUNTIME_ROTATION)
// Rotate before any layout exists, then take the resolution from the
// rotated panel itself: the layout's nominal size must not be substituted
// here, or panels larger than the layout (e.g. 320x480) would be told the
// wrong size.
ScreenRotation::load();
{
ISpiLock::Guard bus; // setRotation talks to the panel
lgfx->setRotation(ScreenRotation::panelRotation());
}
ILOG_DEBUG("Set display resolution: %dx%d, rotation %d", lgfx->width(), lgfx->height(), ScreenRotation::panelRotation());
lv_display_set_resolution(this->display, lgfx->width(), lgfx->height());
#elif defined(DISPLAY_SET_RESOLUTION)
ILOG_DEBUG("Set display resolution: %dx%d", lgfx->screenWidth, lgfx->screenHeight);
lv_display_set_resolution(this->display, lgfx->screenWidth, lgfx->screenHeight);
#endif
Expand Down
Loading
Loading