refactor(cleanup): enable profile creation/deletion through ui

This commit is contained in:
PancakeTAS 2025-12-23 07:17:02 +01:00
parent effd469b5e
commit 08f7c77fd2
13 changed files with 99 additions and 571 deletions

View file

@ -25,19 +25,19 @@ namespace ls {
/// game profile configuration
struct GameConf {
/// name of the profile
std::string name;
std::string name{"Profile"};
/// optional activation string/array
std::vector<std::string> active_in;
/// gpu to use (in case of multiple)
std::optional<std::string> gpu;
/// multiplier for frame generation
size_t multiplier;
size_t multiplier{2};
/// non-inverted flow scale
float flow_scale;
float flow_scale{1.00F};
/// use performance mode
bool performance_mode;
bool performance_mode{false};
/// pacing method
Pacing pacing;
Pacing pacing{Pacing::None};
};
/// automatically updating configuration

View file

@ -5,6 +5,7 @@ set(UI_SOURCES
"src/main.cpp")
set(UI_RESOURCES
"rsc/panes/CenteredDialog.qml"
"rsc/panes/Group.qml"
"rsc/panes/GroupEntry.qml"
"rsc/panes/Pane.qml"

View file

@ -13,48 +13,41 @@ ApplicationWindow {
minimumHeight: 400
visible: true
Dialog {
id: dialog_name
title: "(...)"
standardButtons: Dialog.Ok | Dialog.Cancel
CenteredDialog {
id: create_dialog
name: "Create New Profile"
onConfirm: backend.createProfile(create_name.text)
modal: true
dim: true
x: (parent.width - width) / 2
y: (parent.height - height) / 2
contentItem: ColumnLayout {
spacing: 8
TextField {
Layout.fillWidth: true
id: nameField
placeholderText: "Choose a profile name"
selectByMouse: true
focus: true
}
TextField {
Layout.fillWidth: true
id: create_name
placeholderText: "Choose a profile name"
focus: true
}
}
Dialog {
id: dialog_confirm
title: "Confirm Deletion"
standardButtons: Dialog.Ok | Dialog.Cancel
CenteredDialog {
id: rename_dialog
name: "Rename Profile"
onConfirm: backend.renameProfile(rename_name.text)
modal: true
dim: true
x: (parent.width - width) / 2
y: (parent.height - height) / 2
TextField {
Layout.fillWidth: true
id: rename_name
placeholderText: "Choose a profile name"
focus: true
}
}
contentItem: ColumnLayout {
spacing: 8
CenteredDialog {
id: delete_dialog
name: "Confirm Deletion"
onConfirm: backend.deleteProfile()
Label {
Layout.fillWidth: true
text: "Are you sure you want to delete the selected profile?"
horizontalAlignment: Text.AlignHCenter
}
Label {
Layout.fillWidth: true
text: "Are you sure you want to delete the selected profile?"
horizontalAlignment: Text.AlignHCenter
}
}
@ -84,27 +77,24 @@ ApplicationWindow {
Layout.fillWidth: true
text: "Create New Profile"
onClicked: {
dialog_name.title = "Create New Profile"
nameField.text = ""
dialog_name.open()
create_name.text = ""
create_dialog.open()
}
}
Button {
Layout.fillWidth: true
text: "Rename Profile"
onClicked: {
dialog_name.title = "Rename Profile"
nameField.text = "(...)"
dialog_name.open()
var idx = backend.profiles.index(backend.profile_index, 0);
rename_name.text = backend.profiles.data(idx);
rename_dialog.open()
}
}
Button {
Layout.fillWidth: true
text: "Delete Profile"
onClicked: {
dialog_confirm.open()
delete_dialog.open()
}
}
}

View file

@ -0,0 +1,24 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Dialog {
property string name
default property alias content: inner.children
signal confirm()
id: root
title: name
standardButtons: Dialog.Ok | Dialog.Cancel
onAccepted: root.confirm()
modal: true
dim: true
x: (parent.width - width) / 2
y: (parent.height - height) / 2
contentItem: ColumnLayout {
id: inner
spacing: 8
}
}

View file

@ -12,7 +12,7 @@ using namespace lsfgvk::ui;
Backend::Backend() {
// load configuration
ls::Configuration config{false};
ls::Configuration config;
config.reload();
this->m_global = config.getGlobalConf();

View file

@ -139,6 +139,40 @@ namespace lsfgvk::ui {
MARK_DIRTY()
}
Q_INVOKABLE void createProfile(const QString& name) {
ls::GameConf conf;
conf.name = name.toStdString();
this->m_profiles.push_back(std::move(conf));
auto& model = this->m_profile_list_model;
model->insertRow(model->rowCount());
model->setData(model->index(model->rowCount() - 1), name);
this->m_profile_index = static_cast<int>(this->m_profiles.size() - 1);
MARK_DIRTY()
}
Q_INVOKABLE void renameProfile(const QString& name) {
VALIDATE_AND_GET_PROFILE()
conf.name = name.toStdString();
auto& model = this->m_profile_list_model;
model->setData(model->index(this->m_profile_index), name);
MARK_DIRTY()
}
Q_INVOKABLE void deleteProfile() {
if (!isValidProfileIndex())
return;
auto& profiles = this->m_profiles;
profiles.erase(profiles.begin() + this->m_profile_index);
auto& model = this->m_profile_list_model;
model->removeRow(this->m_profile_index);
if (!this->m_profiles.empty())
this->m_profile_index = 0;
else
this->m_profile_index = -1;
MARK_DIRTY()
}
#undef VALIDATE_AND_GET_PROFILE
#undef MARK_DIRTY

View file

@ -1,95 +0,0 @@
use serde::{Deserialize, Serialize};
// multiplier
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Multiplier(i64);
impl Default for Multiplier {
fn default() -> Self { Multiplier(2) }
}
impl From<i64> for Multiplier {
fn from(value: i64) -> Self { Multiplier(value) }
}
impl Into<f64> for Multiplier {
fn into(self) -> f64 { self.0 as f64 }
}
// flow scale
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlowScale(f64);
impl Default for FlowScale {
fn default() -> Self { FlowScale(1.0) }
}
impl From<f64> for FlowScale {
fn from(value: f64) -> Self { FlowScale(value) }
}
impl Into<f64> for FlowScale {
fn into(self) -> f64 { self.0 }
}
// present mode
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum PresentMode {
#[serde(rename = "fifo", alias = "vsync")]
Vsync,
#[serde(rename = "immediate")]
Immediate,
#[serde(rename = "mailbox")]
Mailbox,
}
impl Default for PresentMode {
fn default() -> Self { PresentMode::Vsync }
}
impl From<i64> for PresentMode {
fn from(value: i64) -> Self {
match value {
0 => PresentMode::Vsync,
1 => PresentMode::Mailbox,
2 => PresentMode::Immediate,
_ => PresentMode::Vsync,
}
}
}
impl Into<u32> for PresentMode {
fn into(self) -> u32 {
match self {
PresentMode::Vsync => 0,
PresentMode::Mailbox => 1,
PresentMode::Immediate => 2,
}
}
}
/// Global configuration for the application
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct TomlGlobal {
pub dll: Option<String>,
#[serde(default)]
pub no_fp16: bool
}
/// Game-specific configuration
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct TomlGame {
pub exe: String,
#[serde(default)]
pub multiplier: Multiplier,
#[serde(default)]
pub flow_scale: FlowScale,
#[serde(default)]
pub performance_mode: bool,
#[serde(default)]
pub hdr_mode: bool,
#[serde(default)]
pub experimental_present_mode: PresentMode
}
/// Main configuration structure
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct TomlConfig {
pub version: i64,
#[serde(default)]
pub global: TomlGlobal,
#[serde(default)]
pub game: Vec<TomlGame>
}

View file

@ -1,43 +0,0 @@
use adw::{self, subclass::prelude::ObjectSubclassIsExt};
use gtk::prelude::{WidgetExt, EditableExt, GtkWindowExt};
use crate::config;
use crate::wrapper;
pub mod entry_handler;
pub mod main_handler;
pub mod sidebar_handler;
pub fn build(app: &adw::Application) {
// create the main window
let window = wrapper::Window::new(app);
window.set_application(Some(app));
let imp = window.imp();
// load profiles from configuration
let config = config::get_config().unwrap();
for game in config.game.iter() {
let entry = wrapper::entry::Entry::new();
entry.set_exe(game.exe.clone());
entry_handler::add_entry(entry, imp.sidebar.imp().profiles.clone());
}
if let Some(dll_path) = config.global.dll {
imp.main.imp().dll.imp().entry.set_text(&dll_path);
}
imp.main.imp().no_fp16.imp().switch.set_active(config.global.no_fp16);
// register handlers on sidebar pane.
sidebar_handler::register_signals(&imp.sidebar, imp.main.clone());
// register handlers on main pane.
main_handler::register_signals(imp.sidebar.clone(), &imp.main);
// activate the first profile if available
if let Some(entry) = imp.sidebar.imp().profiles.row_at_index(0) {
entry.activate();
}
// present the window
window.present();
}

View file

@ -1,54 +0,0 @@
use adw::subclass::prelude::ObjectSubclassIsExt;
use gtk::{gio, glib::object::CastNone, prelude::{ButtonExt, ListBoxRowExt, WidgetExt}};
use crate::{config, wrapper::entry, STATE};
///
/// Register signals for removing presets when adding a new entry.
///
pub fn add_entry(entry_: entry::Entry, profiles_: gtk::ListBox) {
let entry = entry_.clone();
let profiles = profiles_.clone();
entry_.imp().delete.connect_clicked(move |btn| {
// prompt for confirmation
let dialog = gtk::AlertDialog::builder()
.message("Delete Profile")
.detail("Are you sure you want to delete this profile?")
.buttons(vec!["Cancel".to_string(), "Delete".to_string()])
.cancel_button(0)
.default_button(1)
.modal(true)
.build();
let window = btn.root()
.and_downcast::<gtk::Window>()
.expect("Button root is not a Window");
let profiles = profiles.clone();
let entry = entry.clone();
dialog.choose(Some(&window), gio::Cancellable::NONE, move |result| {
if result.is_err() || result.unwrap() != 1 {
return;
}
// remove config entry
let _ = config::edit_config(|config| {
config.game.remove(entry.index() as usize);
});
// remove ui entry
profiles.remove(&entry);
// select next entry
let state = STATE.get().unwrap().clone();
if let Ok(mut state) = state.write() {
state.selected_game = None;
}
if let Some(entry) = profiles.row_at_index(0) {
entry.activate();
}
});
});
profiles_.append(&entry_);
}

View file

@ -1,176 +0,0 @@
use adw::subclass::prelude::ObjectSubclassIsExt;
use gtk::{gio::{self, prelude::FileExt}, glib::object::CastNone, prelude::{ButtonExt, EditableExt, GtkWindowExt, ListBoxRowExt, RangeExt, WidgetExt}};
use crate::{config, utils, wrapper::{entry, pane, popup}, STATE};
// update the currently selected game configuration
fn update_game<F: FnOnce(&mut config::TomlGame)>(update: F) {
if let Ok(state) = STATE.get().unwrap().try_read() {
if let Some(selected_game) = state.selected_game {
let _ = config::edit_config(|config| {
update(&mut config.game[selected_game])
});
}
}
}
///
/// Register signals for preset preferences.
///
pub fn register_signals(sidebar_: pane::PaneSidebar, main: &pane::PaneMain) {
let main = main.imp();
let exe = main.profile_name.imp();
let multiplier = main.multiplier.imp();
let flow_scale = main.flow_scale.imp();
let performance_mode = main.performance_mode.imp();
let hdr_mode = main.hdr_mode.imp();
let experimental_present_mode = main.experimental_present_mode.imp();
// preset opts
let sidebar = sidebar_.clone();
exe.entry.connect_changed(move |entry| {
let mut exe = entry.text().to_string();
if exe.trim().is_empty() {
exe = "new preset".to_string();
}
// rename list entry
let row_option = sidebar.imp().profiles.selected_row()
.and_downcast::<entry::Entry>();
if let Some(row) = row_option {
row.set_exe(exe.clone());
}
// update the game configuration
update_game(|conf| {
conf.exe = exe;
});
});
multiplier.number.connect_value_changed(|dropdown| {
update_game(|conf| {
conf.multiplier = (dropdown.value() as i64).into();
})
});
flow_scale.slider.connect_value_changed(|slider| {
update_game(|conf| {
conf.flow_scale = (slider.value() / 100.0).into();
});
});
performance_mode.switch.connect_state_notify(|switch| {
update_game(|conf| {
conf.performance_mode = switch.state();
});
});
hdr_mode.switch.connect_state_notify(|switch| {
update_game(|conf| {
conf.hdr_mode = switch.state();
});
});
experimental_present_mode.dropdown.connect_selected_notify(|dropdown| {
update_game(|conf| {
conf.experimental_present_mode = match dropdown.selected() {
0 => config::PresentMode::Vsync,
1 => config::PresentMode::Mailbox,
2 => config::PresentMode::Immediate,
_ => config::PresentMode::Vsync,
};
});
});
// global opts
let dll = main.dll.imp();
dll.entry.connect_changed(|entry| {
let _ = config::edit_config(|config| {
let mut text = entry.text().to_string();
if text.trim().is_empty() {
config.global.dll = None;
} else {
if text.contains("~") {
let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/"));
text = text.replace("~", &home);
}
config.global.dll = Some(text);
}
});
});
let no_fp16 = main.no_fp16.imp();
no_fp16.switch.connect_state_notify(|switch| {
let _ = config::edit_config(|config| {
config.global.no_fp16 = switch.state();
});
});
// utility buttons
let entry = dll.entry.clone();
dll.btn.connect_clicked(move |btn| {
let dialog = gtk::FileDialog::new();
dialog.set_title("Select Lossless.dll");
let filter = gtk::FileFilter::new();
filter.set_name(Some("Lossless.dll"));
filter.add_pattern("Lossless.dll");
let filters = gio::ListStore::new::<gtk::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
dialog.set_default_filter(Some(&filter));
let window = btn.root()
.and_downcast::<gtk::Window>()
.unwrap();
let entry = entry.clone();
dialog.open(Some(&window), gio::Cancellable::NONE, move |result| {
if result.is_err() || result.as_ref().unwrap().path().is_none() {
return;
}
let path = result.unwrap().path().unwrap();
let path_str = path.to_string_lossy().to_string();
entry.set_text(&path_str);
let _ = config::edit_config(|config| {
config.global.dll = Some(path_str);
});
});
});
let entry = exe.entry.clone();
exe.btn.connect_clicked(move |btn| {
let window = btn.root()
.and_downcast::<gtk::ApplicationWindow>()
.unwrap()
.application()
.unwrap();
let picker = popup::ProcessPicker::new();
picker.set_application(Some(&window));
let list = picker.imp().processes.clone();
let processes = utils::find_vulkan_processes().unwrap_or_default();
for process in &processes {
let entry = popup::ProcessEntry::new();
entry.set_exe(process.0.clone());
list.append(&entry);
}
let entry = entry.clone();
let picker_ = picker.clone();
picker.imp().processes.connect_row_activated(move |_, row| {
let comm_str = processes[row.index() as usize].1.clone();
entry.set_text(&comm_str);
update_game(|conf| {
conf.exe = comm_str;
});
picker_.close();
});
let picker_ = picker.clone();
picker.imp().close.connect_clicked(move |_| {
picker_.close();
});
picker.present();
});
}

View file

@ -1,69 +0,0 @@
use adw::subclass::prelude::ObjectSubclassIsExt;
use gtk::prelude::{ButtonExt, EditableExt, ListBoxRowExt, RangeExt, WidgetExt};
use crate::{config, ui::entry_handler, wrapper::{pane, entry}, STATE};
///
/// Register signals for adding and selecting presets.
///
pub fn register_signals(sidebar_: &pane::PaneSidebar, main: pane::PaneMain) {
// activate signal
let state = STATE.get().unwrap().clone();
sidebar_.imp().profiles.connect_row_activated(move |_, entry| {
// find config entry by index
let index = entry.index() as usize;
let config = config::get_config();
if config.is_err() {
return;
}
let config = config.unwrap();
let conf = config.game[index].clone();
// update main pane
let main = main.imp();
let exe = main.profile_name.imp();
let multiplier = main.multiplier.imp();
let flow_scale = main.flow_scale.imp();
let performance_mode = main.performance_mode.imp();
let hdr_mode = main.hdr_mode.imp();
let experimental_present_mode = main.experimental_present_mode.imp();
// (lock state early, so the ui update doesn't override the config)
if let Ok(mut state) = state.write() {
exe.entry.set_text(&conf.exe);
multiplier.number.set_value(conf.multiplier.into());
flow_scale.slider.set_value(Into::<f64>::into(conf.flow_scale) * 100.0);
performance_mode.switch.set_active(conf.performance_mode);
hdr_mode.switch.set_active(conf.hdr_mode);
experimental_present_mode.dropdown.set_selected(conf.experimental_present_mode.into());
// update state
state.selected_game = Some(index);
}
});
// create signal
let sidebar = sidebar_.clone();
sidebar_.imp().create.connect_clicked(move |_| {
// ensure no config entry with the same name exist
let config = config::get_config().unwrap();
if config.game.iter().any(|e| e.exe == "new profile") {
return;
}
// create config entry
let mut conf_entry = config::TomlGame::default();
conf_entry.exe = "new profile".to_string();
let _ = config::edit_config(|config| {
config.game.push(conf_entry.clone());
});
// add entry to sidebar
let entry = entry::Entry::new();
entry.set_exe(conf_entry.exe);
entry_handler::add_entry(entry.clone(), sidebar.imp().profiles.clone());
// select the new entry
entry.activate();
});
}

View file

@ -1,46 +0,0 @@
use procfs::{process, ProcResult};
pub fn find_vulkan_processes() -> ProcResult<Vec<(String, String)>> {
let mut processes = Vec::new();
let apps = process::all_processes()?;
for app in apps {
let Ok(prc) = app else { continue; };
// ensure vulkan is loaded
let Ok(maps) = proc_maps::get_process_maps(prc.pid()) else {
continue;
};
let result = maps.iter()
.filter_map(|map| map.filename())
.map(|filename| filename.to_string_lossy().to_string())
.any(|filename| filename.to_lowercase().contains("vulkan"));
if !result {
continue;
}
// find executed binary
let mut exe = prc.exe()?.to_string_lossy().to_string();
// replace binary with exe for wine apps
if exe.contains("wine") || exe.contains("proton") {
let result = maps.iter()
.filter_map(|map| map.filename())
.map(|filename| filename.to_string_lossy().to_string())
.find(|filename| filename.ends_with(".exe"));
if let Some(exe_name) = result {
exe = exe_name;
}
}
// split off last part of the path
exe = exe.split('/').last().unwrap_or(&exe).to_string();
// format process information
let pid = prc.pid();
let process_info = format!("PID {}: {}", pid, exe);
processes.push((process_info, exe));
}
Ok(processes)
}

View file

@ -1,38 +0,0 @@
use gtk::glib;
use gtk;
use adw;
pub mod process;
pub mod process_entry;
glib::wrapper! {
pub struct ProcessPicker(ObjectSubclass<process::ProcessPicker>)
@extends
adw::ApplicationWindow, adw::Window,
gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements
gtk::gio::ActionGroup, gtk::gio::ActionMap,
gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget,
gtk::Native, gtk::Root, gtk::ShortcutManager;
}
glib::wrapper! {
pub struct ProcessEntry(ObjectSubclass<process_entry::ProcessEntry>)
@extends
gtk::ListBoxRow, gtk::Widget,
@implements
gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget;
}
impl ProcessPicker {
pub fn new() -> Self {
glib::Object::new()
}
}
impl ProcessEntry {
pub fn new() -> Self {
glib::Object::new()
}
}