daniel/theme-setting (#87)

- Themes can be loaded from disk and built-in
- Themes can be selected in a new themes menu of the settings screen
- Some touch-ups to existing themes
- The saved theme is persisted in nvs

Reviewed-on: https://codeberg.org/cool-tech-zone/tangara-fw/pulls/87
Reviewed-by: cooljqln <cooljqln@noreply.codeberg.org>
Co-authored-by: ailurux <ailuruxx@gmail.com>
Co-committed-by: ailurux <ailuruxx@gmail.com>
custom
ailurux 8 months ago committed by cooljqln
parent 3d7b005dc9
commit d719f9c501
  1. BIN
      lua/img/next.png
  2. BIN
      lua/img/pause.png
  3. BIN
      lua/img/play.png
  4. BIN
      lua/img/prev.png
  5. 12
      lua/main.lua
  6. 7
      lua/main_menu.lua
  7. 23
      lua/playing.lua
  8. 61
      lua/settings.lua
  9. 29
      lua/theme_dark.lua
  10. 78
      lua/theme_light.lua
  11. 28
      luals-stubs/theme.lua
  12. 5
      src/drivers/include/drivers/nvs.hpp
  13. 39
      src/drivers/nvs.cpp
  14. 33
      src/tangara/lua/lua_theme.cpp
  15. 5
      src/tangara/ui/themes.cpp
  16. 4
      src/tangara/ui/themes.hpp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

@ -14,9 +14,15 @@ local backstack = require("backstack")
local main_menu = require("main_menu")
local function init_ui()
-- Load the theme within init_ui because the theme needs fonts to be ready.
local theme_dark = require("theme_dark")
theme.set(theme_dark)
-- Theme is set within init_ui because the theme needs fonts to be ready.
-- Set the theme to the saved theme if available
local saved_theme = theme.theme_filename()
local res = theme.load_theme(saved_theme)
if not res then
-- Set a default theme (in case the saved theme does not load)
local default_theme = require("theme_light")
theme.set(default_theme)
end
local lock_time = time.ticks()

@ -33,9 +33,9 @@ return widgets.MenuScreen:new {
margin_all = 2,
pad_bottom = 2,
pad_column = 4,
border_color = "#FFFFFF",
border_width = 1,
})
theme.set_style(now_playing, "now_playing");
local play_pause = now_playing:Image { src = img.play_small }
local title = now_playing:Label {
@ -140,6 +140,7 @@ return widgets.MenuScreen:new {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_top = 4,
pad_bottom = 2,
})
-- local queue_btn = bottom_bar:Button {}
@ -154,13 +155,13 @@ return widgets.MenuScreen:new {
})
end)
files_btn:Image { src = img.files }
theme.set_style(files_btn, "icon_enabled")
theme.set_style(files_btn, "menu_icon")
local settings_btn = bottom_bar:Button {}
settings_btn:onClicked(function()
backstack.push(require("settings"):new())
end)
settings_btn:Image { src = img.settings }
theme.set_style(settings_btn, "icon_enabled")
theme.set_style(settings_btn, "menu_icon")
end,
}

@ -74,7 +74,7 @@ return screen:new {
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
w = lvgl.PCT(95),
h = lvgl.SIZE_CONTENT,
}
@ -114,11 +114,12 @@ return screen:new {
playlist:Object({ w = 3, h = 1 }) -- spacer
local scrubber = self.root:Slider {
w = lvgl.PCT(100),
w = lvgl.PCT(90),
h = 5,
range = { min = 0, max = 100 },
value = 0,
}
theme.set_style(scrubber, "scrubber");
local scrubber_desc = widgets.Description(scrubber, "Scrubber")
scrubber:onevent(lvgl.EVENT.RELEASED, function()
@ -138,6 +139,8 @@ return screen:new {
end
end)
self.root:Object({ w = 1, h = 1 }) -- spacer
local controls = self.root:Object {
flex = {
flex_direction = "row",
@ -158,7 +161,7 @@ return screen:new {
queue.repeat_track:set(not queue.repeat_track:get())
end)
local repeat_img = repeat_btn:Image { src = img.repeat_src }
theme.set_style(repeat_img, icon_enabled_class)
theme.set_style(repeat_btn, icon_enabled_class)
local repeat_desc = widgets.Description(repeat_btn)
@ -171,7 +174,7 @@ return screen:new {
end
end)
local prev_img = prev_btn:Image { src = img.prev }
theme.set_style(prev_img, icon_enabled_class)
theme.set_style(prev_btn, icon_enabled_class)
local prev_desc = widgets.Description(prev_btn, "Previous track")
local play_pause_btn = controls:Button {}
@ -180,13 +183,13 @@ return screen:new {
end)
play_pause_btn:focus()
local play_pause_img = play_pause_btn:Image { src = img.pause }
theme.set_style(play_pause_img, icon_enabled_class)
theme.set_style(play_pause_btn, icon_enabled_class)
local play_pause_desc = widgets.Description(play_pause_btn, "Play")
local next_btn = controls:Button {}
next_btn:onClicked(queue.next)
local next_img = next_btn:Image { src = img.next }
theme.set_style(next_img, icon_disabled_class)
theme.set_style(next_btn, icon_disabled_class)
local next_desc = widgets.Description(next_btn, "Next track")
local shuffle_btn = controls:Button {}
@ -194,7 +197,7 @@ return screen:new {
queue.random:set(not queue.random:get())
end)
local shuffle_img = shuffle_btn:Image { src = img.shuffle }
theme.set_style(shuffle_img, icon_enabled_class)
theme.set_style(shuffle_btn, icon_enabled_class)
local shuffle_desc = widgets.Description(shuffle_btn)
controls:Object({ flex_grow = 1, h = 1 }) -- spacer
@ -238,11 +241,11 @@ return screen:new {
local can_next = pos < queue.size:get() or queue.random:get()
theme.set_style(
next_img, can_next and icon_enabled_class or icon_disabled_class
next_btn, can_next and icon_enabled_class or icon_disabled_class
)
end),
queue.random:bind(function(shuffling)
theme.set_style(shuffle_img, shuffling and icon_enabled_class or icon_disabled_class)
theme.set_style(shuffle_btn, shuffling and icon_enabled_class or icon_disabled_class)
if shuffling then
shuffle_desc:set { text = "Disable shuffle" }
else
@ -250,7 +253,7 @@ return screen:new {
end
end),
queue.repeat_track:bind(function(en)
theme.set_style(repeat_img, en and icon_enabled_class or icon_disabled_class)
theme.set_style(repeat_btn, en and icon_enabled_class or icon_disabled_class)
if en then
repeat_desc:set { text = "Disable track repeat" }
else

@ -7,9 +7,11 @@ local display = require("display")
local controls = require("controls")
local bluetooth = require("bluetooth")
local theme = require("theme")
local filesystem = require("filesystem")
local database = require("database")
local usb = require("usb")
local font = require("font")
local main_menu = require("main_menu")
local SettingsScreen = widgets.MenuScreen:new {
show_back = true,
@ -301,6 +303,64 @@ local DisplaySettings = SettingsScreen:new {
end
}
local ThemeSettings = SettingsScreen:new {
title = "Theme",
createUi = function(self)
SettingsScreen.createUi(self)
theme.set_style(self.content:Label {
text = "Theme",
}, "settings_title")
local themeOptions = {}
themeOptions["Dark"] = "/lua/theme_dark.lua"
themeOptions["Light"] = "/lua/theme_light.lua"
-- Parse theme directory for more themes
local theme_dir_iter = filesystem.iterator("/.themes/")
for dir in theme_dir_iter do
local theme_name = tostring(dir):match("(.+).lua$")
themeOptions[theme_name] = "/sdcard/.themes/" .. theme_name .. ".lua"
end
local saved_theme = theme.theme_filename();
local saved_theme_name = saved_theme:match(".+/(.*).lua$")
local options = ""
local idx = 0
local selected_idx = -1
for i, v in pairs(themeOptions) do
if (saved_theme == v) then
selected_idx = idx
end
if idx > 0 then
options = options .. "\n"
end
options = options .. i
idx = idx + 1
end
if (selected_idx == -1) then
options = options .. "\n" .. saved_theme_name
selected_idx = idx
end
local theme_chooser = self.content:Dropdown {
options = options,
}
theme_chooser:set({selected = selected_idx})
theme_chooser:onevent(lvgl.EVENT.VALUE_CHANGED, function()
local option = theme_chooser:get('selected_str')
local selectedTheme = themeOptions[option]
if (selectedTheme) then
theme.load_theme(tostring(selectedTheme))
backstack.reset(main_menu:new())
end
end)
end
}
local InputSettings = SettingsScreen:new {
title = "Input Method",
createUi = function(self)
@ -742,6 +802,7 @@ return widgets.MenuScreen:new {
section("Interface")
submenu("Display", DisplaySettings)
submenu("Theme", ThemeSettings)
submenu("Input Method", InputSettings)
section("USB")

@ -95,24 +95,27 @@ local theme_dark = {
switch = {
{lvgl.PART.MAIN, lvgl.Style {
bg_opa = lvgl.OPA(100),
width = 28,
height = 8,
width = 18,
height = 10,
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
bg_color = background_muted,
border_color = highlight_color,
border_color = text_color,
border_width = 1,
}},
{lvgl.PART.INDICATOR, lvgl.Style {
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
bg_color = background_muted,
bg_color = background_color,
}},
{lvgl.PART.INDICATOR | lvgl.STATE.CHECKED, lvgl.Style {
bg_color = highlight_color,
bg_opa = lvgl.OPA(100),
}},
{lvgl.PART.KNOB, lvgl.Style {
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
pad_all = 2,
bg_opa = lvgl.OPA(100),
bg_color = background_muted,
border_color = text_color,
border_width = 1,
}},
{lvgl.PART.KNOB | lvgl.STATE.FOCUSED, lvgl.Style {
bg_color = highlight_color,
@ -170,7 +173,21 @@ local theme_dark = {
image_recolor = icon_enabled_color,
}},
},
now_playing = {
{lvgl.PART.MAIN, lvgl.Style {
bg_opa = lvgl.OPA(100),
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
border_width = 1,
border_color = highlight_color,
border_side = 15, -- LV_BORDER_SIDE_FULL
}},
},
menu_icon = {
{lvgl.PART.MAIN, lvgl.Style {
pad_all = 4,
radius = 4
}},
},
}
return theme_dark

@ -2,10 +2,10 @@ local lvgl = require("lvgl")
local font = require("font")
local background_color = "#ffffff"
local background_muted = "#fafafa"
local text_color = "#000000"
local highlight_color = "#ce93d8"
local icon_enabled_color = "#2c2c2c"
local background_muted = "#f2f2f2"
local text_color = "#2c2c2c"
local highlight_color = "#ff82bc"
local icon_enabled_color = "#ff82bc"
local icon_disabled_color = "#999999"
local theme_light = {
@ -47,6 +47,7 @@ local theme_light = {
}},
{lvgl.PART.MAIN | lvgl.STATE.FOCUSED, lvgl.Style {
bg_opa = lvgl.OPA(100),
text_color = "#ffffff",
bg_color = highlight_color,
image_recolor_opa = 0,
}},
@ -55,6 +56,7 @@ local theme_light = {
{lvgl.PART.MAIN | lvgl.STATE.FOCUSED, lvgl.Style {
bg_opa = lvgl.OPA(100),
bg_color = highlight_color,
text_color = "#ffffff"
}},
},
bar = {
@ -75,16 +77,41 @@ local theme_light = {
}},
{lvgl.PART.KNOB, lvgl.Style {
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
pad_all = 2,
bg_color = background_muted,
shadow_width = 5,
shadow_opa = lvgl.OPA(100)
shadow_opa = lvgl.OPA(100),
pad_all = 2,
}},
{lvgl.PART.MAIN | lvgl.STATE.FOCUSED, lvgl.Style {
bg_color = background_muted,
}},
{lvgl.PART.KNOB | lvgl.STATE.FOCUSED, lvgl.Style {
bg_color = highlight_color,
}},
{lvgl.PART.INDICATOR | lvgl.STATE.CHECKED, lvgl.Style {
bg_color = highlight_color,
}},
},
scrubber = {
{lvgl.PART.MAIN, lvgl.Style {
bg_opa = lvgl.OPA(100),
bg_color = background_muted,
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
}},
{lvgl.PART.INDICATOR, lvgl.Style {
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
bg_color = highlight_color,
}},
{lvgl.PART.KNOB, lvgl.Style {
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
bg_color = background_muted,
}},
{lvgl.PART.MAIN | lvgl.STATE.FOCUSED, lvgl.Style {
bg_color = background_muted,
}},
{lvgl.PART.KNOB | lvgl.STATE.FOCUSED, lvgl.Style {
bg_color = highlight_color,
pad_all = 1,
}},
{lvgl.PART.INDICATOR | lvgl.STATE.CHECKED, lvgl.Style {
bg_color = highlight_color,
@ -93,24 +120,27 @@ local theme_light = {
switch = {
{lvgl.PART.MAIN, lvgl.Style {
bg_opa = lvgl.OPA(100),
width = 28,
height = 8,
width = 18,
height = 10,
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
bg_color = background_muted,
border_color = highlight_color,
border_color = text_color,
border_width = 1,
}},
{lvgl.PART.INDICATOR, lvgl.Style {
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
bg_color = background_muted,
bg_color = background_color,
}},
{lvgl.PART.INDICATOR | lvgl.STATE.CHECKED, lvgl.Style {
bg_opa = lvgl.OPA(100),
bg_color = highlight_color,
}},
{lvgl.PART.KNOB, lvgl.Style {
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
pad_all = 2,
bg_opa = lvgl.OPA(100),
bg_color = background_muted,
bg_color = background_color,
border_color = text_color,
border_width = 1,
}},
{lvgl.PART.KNOB | lvgl.STATE.FOCUSED, lvgl.Style {
bg_color = highlight_color,
@ -161,14 +191,36 @@ local theme_light = {
image_recolor_opa = 180,
image_recolor = icon_disabled_color,
}},
{lvgl.PART.MAIN | lvgl.STATE.FOCUSED, lvgl.Style {
image_recolor_opa = 0,
image_recolor = "#ffffff",
}},
},
icon_enabled = {
{lvgl.PART.MAIN, lvgl.Style {
image_recolor_opa = 180,
image_recolor = icon_enabled_color,
}},
{lvgl.PART.MAIN | lvgl.STATE.FOCUSED, lvgl.Style {
image_recolor_opa = 0,
image_recolor = "#ffffff",
}},
},
now_playing = {
{lvgl.PART.MAIN, lvgl.Style {
bg_opa = lvgl.OPA(100),
radius = 32767, -- LV_RADIUS_CIRCLE = 0x7fff
border_width = 1,
border_color = highlight_color,
border_side = 15, -- LV_BORDER_SIDE_FULL
}},
},
menu_icon = {
{lvgl.PART.MAIN, lvgl.Style {
pad_all = 4,
radius = 4
}},
},
}
return theme_light

@ -0,0 +1,28 @@
--- @meta
--- @class theme
local theme = {}
--- Loads a theme from a filename, this can be either builtin (ie, located in
--- "/lua/") or on the sdcard (in, '/sdcard/.themes/')
--- If successful, the filename will be saved to non-volatile storage.
--- Returns whether the theme was successfully loaded
--- @param filename string
--- @return boolean
function theme.load_theme(filename) end
--- Sets a theme directly from a table. Does not persist between restarts.
--- @param theme
function theme.set(theme) end
--- Set the style name (similar in concept to a css selector) for an object
--- This will set any styles associated with that style name on the object
--- @param obj Object The object to set a particular style on
--- @param style string The name of the style to apply to this object
function theme.set_style(obj, style) end
--- Returns the filename of the saved theme
--- @return string
function theme.theme_filename() end
return theme

@ -113,6 +113,9 @@ class NvsStorage {
auto ScreenBrightness() -> uint_fast8_t;
auto ScreenBrightness(uint_fast8_t) -> void;
auto InterfaceTheme() -> std::optional<std::string>;
auto InterfaceTheme(std::string) -> void;
auto ScrollSensitivity() -> uint_fast8_t;
auto ScrollSensitivity(uint_fast8_t) -> void;
@ -163,6 +166,8 @@ class NvsStorage {
Setting<uint8_t> input_mode_;
Setting<uint8_t> output_mode_;
Setting<std::string> theme_;
Setting<bluetooth::MacAndName> bt_preferred_;
Setting<std::vector<bluetooth::MacAndName>> bt_names_;

@ -29,6 +29,7 @@ static constexpr char kKeyBluetoothVolumes[] = "bt_vols";
static constexpr char kKeyBluetoothNames[] = "bt_names";
static constexpr char kKeyOutput[] = "out";
static constexpr char kKeyBrightness[] = "bright";
static constexpr char kKeyInterfaceTheme[] = "ui_theme";
static constexpr char kKeyAmpMaxVolume[] = "hp_vol_max";
static constexpr char kKeyAmpCurrentVolume[] = "hp_vol";
static constexpr char kKeyAmpLeftBias[] = "hp_bias";
@ -169,6 +170,31 @@ auto Setting<std::vector<bluetooth::MacAndName>>::store(
nvs_set_blob(nvs, name_, encoded.data(), encoded.size());
}
template <>
auto Setting<std::string>::store(
nvs_handle_t nvs,
std::string v) -> void {
cppbor::Tstr cbor{v};
auto encoded = cbor.encode();
nvs_set_blob(nvs, name_, encoded.data(), encoded.size());
}
template <>
auto Setting<std::string>::load(nvs_handle_t nvs)
-> std::optional<std::string> {
auto raw = nvs_get_string(nvs, name_);
if (!raw) {
return {};
}
auto [parsed, unused, err] = cppbor::parseWithViews(
reinterpret_cast<const uint8_t*>(raw->data()), raw->size());
if (parsed->type() != cppbor::TSTR) {
return {};
}
auto v = parsed->asViewTstr()->view();
return std::string{v.begin(), v.end()};
}
template <>
auto Setting<NvsStorage::LraData>::load(nvs_handle_t nvs)
-> std::optional<NvsStorage::LraData> {
@ -248,6 +274,7 @@ NvsStorage::NvsStorage(nvs_handle_t handle)
amp_left_bias_(kKeyAmpLeftBias),
input_mode_(kKeyPrimaryInput),
output_mode_(kKeyOutput),
theme_{kKeyInterfaceTheme},
bt_preferred_(kKeyBluetoothPreferred),
bt_names_(kKeyBluetoothNames),
db_auto_index_(kKeyDbAutoIndex),
@ -273,6 +300,7 @@ auto NvsStorage::Read() -> void {
amp_left_bias_.read(handle_);
input_mode_.read(handle_);
output_mode_.read(handle_);
theme_.read(handle_);
bt_preferred_.read(handle_);
bt_names_.read(handle_);
db_auto_index_.read(handle_);
@ -293,6 +321,7 @@ auto NvsStorage::Write() -> bool {
amp_left_bias_.write(handle_);
input_mode_.write(handle_);
output_mode_.write(handle_);
theme_.write(handle_);
bt_preferred_.write(handle_);
bt_names_.write(handle_);
db_auto_index_.write(handle_);
@ -466,6 +495,16 @@ auto NvsStorage::ScreenBrightness(uint_fast8_t val) -> void {
brightness_.set(val);
}
auto NvsStorage::InterfaceTheme() -> std::optional<std::string> {
std::lock_guard<std::mutex> lock{mutex_};
return theme_.get();
}
auto NvsStorage::InterfaceTheme(std::string themeFile) -> void {
std::lock_guard<std::mutex> lock{mutex_};
theme_.set(themeFile);
}
auto NvsStorage::ScrollSensitivity() -> uint_fast8_t {
std::lock_guard<std::mutex> lock{mutex_};
return std::clamp<uint8_t>(sensitivity_.get().value_or(128), 0, 255);

@ -75,8 +75,41 @@ static auto set_theme(lua_State* L) -> int {
return 0;
}
static auto load_theme(lua_State* L) -> int {
std::string filename = luaL_checkstring(L, -1);
// Set the theme filename in non-volatile storage
Bridge* instance = Bridge::Get(L);
// Load the theme using lua
auto status = luaL_loadfile(L, filename.c_str());
if (status != LUA_OK) {
lua_pushboolean(L, false);
return 1;
}
status = lua::CallProtected(L, 0, 1);
if (status == LUA_OK) {
ui::themes::Theme::instance()->Reset();
set_theme(L);
instance->services().nvs().InterfaceTheme(filename);
lua_pushboolean(L, true);
} else {
lua_pushboolean(L, false);
}
return 1;
}
static auto theme_filename(lua_State* L) -> int {
Bridge* instance = Bridge::Get(L);
auto file = instance->services().nvs().InterfaceTheme().value_or("/lua/theme_light.lua");
lua_pushstring(L, file.c_str());
return 1;
}
static const struct luaL_Reg kThemeFuncs[] = {{"set", set_theme},
{"set_style", set_style},
{"load_theme", load_theme},
{"theme_filename", theme_filename},
{NULL, NULL}};
static auto lua_theme(lua_State* L) -> int {

@ -16,6 +16,7 @@
#include "widgets/bar/lv_bar.h"
#include "widgets/button/lv_button.h"
#include "widgets/slider/lv_slider.h"
#include "themes.hpp"
namespace ui {
namespace themes {
@ -81,6 +82,10 @@ void Theme::ApplyStyle(lv_obj_t* obj, std::string style_key) {
}
}
void Theme::Reset() {
style_map.clear();
}
auto Theme::instance() -> Theme* {
static Theme sTheme{};
return &sTheme;

@ -1,6 +1,7 @@
#pragma once
#include <map>
#include <optional>
#include <string>
#include <vector>
#include "lvgl.h"
@ -26,12 +27,15 @@ class Theme {
void AddStyle(std::string key, int selector, lv_style_t* style);
void Reset();
static auto instance() -> Theme*;
private:
Theme();
std::map<std::string, std::vector<std::pair<int, lv_style_t*>>> style_map;
lv_theme_t theme_;
std::optional<std::string> filename_;
};
} // namespace themes
} // namespace ui

Loading…
Cancel
Save