diff --git a/lua/img/next.png b/lua/img/next.png index 1f6f044b..929c5f36 100644 Binary files a/lua/img/next.png and b/lua/img/next.png differ diff --git a/lua/img/pause.png b/lua/img/pause.png index e7011821..32c5a2b4 100644 Binary files a/lua/img/pause.png and b/lua/img/pause.png differ diff --git a/lua/img/play.png b/lua/img/play.png index a3b8a5af..833cb087 100644 Binary files a/lua/img/play.png and b/lua/img/play.png differ diff --git a/lua/img/prev.png b/lua/img/prev.png index b445c75a..714c7c20 100644 Binary files a/lua/img/prev.png and b/lua/img/prev.png differ diff --git a/lua/main.lua b/lua/main.lua index 4cd754b6..9dd88c84 100644 --- a/lua/main.lua +++ b/lua/main.lua @@ -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() diff --git a/lua/main_menu.lua b/lua/main_menu.lua index f3b7a042..54f382a1 100644 --- a/lua/main_menu.lua +++ b/lua/main_menu.lua @@ -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, } diff --git a/lua/playing.lua b/lua/playing.lua index 90e20f49..97997366 100644 --- a/lua/playing.lua +++ b/lua/playing.lua @@ -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 diff --git a/lua/settings.lua b/lua/settings.lua index 79572ee9..3033b36e 100644 --- a/lua/settings.lua +++ b/lua/settings.lua @@ -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") diff --git a/lua/theme_dark.lua b/lua/theme_dark.lua index 6508f642..b9bcece2 100644 --- a/lua/theme_dark.lua +++ b/lua/theme_dark.lua @@ -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 diff --git a/lua/theme_light.lua b/lua/theme_light.lua index 05b7d291..96403de3 100644 --- a/lua/theme_light.lua +++ b/lua/theme_light.lua @@ -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 diff --git a/luals-stubs/theme.lua b/luals-stubs/theme.lua new file mode 100644 index 00000000..4a945cb3 --- /dev/null +++ b/luals-stubs/theme.lua @@ -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 diff --git a/src/drivers/include/drivers/nvs.hpp b/src/drivers/include/drivers/nvs.hpp index e298ffc3..e147c8c7 100644 --- a/src/drivers/include/drivers/nvs.hpp +++ b/src/drivers/include/drivers/nvs.hpp @@ -113,6 +113,9 @@ class NvsStorage { auto ScreenBrightness() -> uint_fast8_t; auto ScreenBrightness(uint_fast8_t) -> void; + auto InterfaceTheme() -> std::optional; + auto InterfaceTheme(std::string) -> void; + auto ScrollSensitivity() -> uint_fast8_t; auto ScrollSensitivity(uint_fast8_t) -> void; @@ -163,6 +166,8 @@ class NvsStorage { Setting input_mode_; Setting output_mode_; + Setting theme_; + Setting bt_preferred_; Setting> bt_names_; diff --git a/src/drivers/nvs.cpp b/src/drivers/nvs.cpp index 6fac8c61..d004201b 100644 --- a/src/drivers/nvs.cpp +++ b/src/drivers/nvs.cpp @@ -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>::store( nvs_set_blob(nvs, name_, encoded.data(), encoded.size()); } +template <> +auto Setting::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::load(nvs_handle_t nvs) + -> std::optional { + auto raw = nvs_get_string(nvs, name_); + if (!raw) { + return {}; + } + auto [parsed, unused, err] = cppbor::parseWithViews( + reinterpret_cast(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::load(nvs_handle_t nvs) -> std::optional { @@ -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::lock_guard lock{mutex_}; + return theme_.get(); +} + +auto NvsStorage::InterfaceTheme(std::string themeFile) -> void { + std::lock_guard lock{mutex_}; + theme_.set(themeFile); +} + auto NvsStorage::ScrollSensitivity() -> uint_fast8_t { std::lock_guard lock{mutex_}; return std::clamp(sensitivity_.get().value_or(128), 0, 255); diff --git a/src/tangara/lua/lua_theme.cpp b/src/tangara/lua/lua_theme.cpp index 5edde104..03578778 100644 --- a/src/tangara/lua/lua_theme.cpp +++ b/src/tangara/lua/lua_theme.cpp @@ -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 { diff --git a/src/tangara/ui/themes.cpp b/src/tangara/ui/themes.cpp index 726bd5f0..3d532d10 100644 --- a/src/tangara/ui/themes.cpp +++ b/src/tangara/ui/themes.cpp @@ -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; diff --git a/src/tangara/ui/themes.hpp b/src/tangara/ui/themes.hpp index fd576478..4826859e 100644 --- a/src/tangara/ui/themes.hpp +++ b/src/tangara/ui/themes.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #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>> style_map; lv_theme_t theme_; + std::optional filename_; }; } // namespace themes } // namespace ui