displays: add monitor config include prompt

parent 18cf5dd4
......@@ -7,6 +7,19 @@ template $TunerDisplaysDisplaysView : Adw.PreferencesPage {
title: _("Displays");
icon-name: "video-display-symbolic";
Adw.PreferencesGroup config_include_group {
Adw.ActionRow config_include_row {
visible: false;
title: _("Monitor configuration is not connected");
[suffix]
Button config_include_button {
valign: center;
label: _("Connect");
}
}
}
Adw.PreferencesGroup status_group {}
Adw.PreferencesGroup layout_group {}
......
......@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: tuner-displays\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-31 21:48+0300\n"
"POT-Creation-Date: 2026-06-15 11:58+0300\n"
"PO-Revision-Date: 2026-05-28 00:00+0000\n"
"Last-Translator: Automatically generated\n"
"Language-Team: Russian\n"
......@@ -15,7 +15,17 @@ msgstr ""
msgid "Displays"
msgstr "Мониторы"
#: data/ui/displays-view.blp:15
#: data/ui/displays-view.blp:13 src/backends/hyprland-backend.vala:40
#: src/backends/hyprland-backend.vala:55 src/backends/niri-backend.vala:46
#: src/backends/niri-backend.vala:61
msgid "Monitor configuration is not connected"
msgstr "Конфигурация мониторов не подключена"
#: data/ui/displays-view.blp:18
msgid "Connect"
msgstr "Подключить"
#: data/ui/displays-view.blp:28
msgid "Details"
msgstr "Параметры"
......@@ -55,11 +65,27 @@ msgstr "Нет включённых мониторов для зеркалиро
msgid "No common mirror mode is available"
msgstr "Нет общего режима для зеркалирования"
#: src/backends/hyprland-backend.vala:39
#: src/backends/hyprland-backend.vala:41
msgid "Hyprland configuration file was not found."
msgstr "Файл конфигурации Hyprland не найден."
#: src/backends/hyprland-backend.vala:56
msgid "Add monitors.conf to the Hyprland configuration."
msgstr "Добавьте monitors.conf в конфигурацию Hyprland."
#: src/backends/hyprland-backend.vala:80
msgid "hyprctl monitors all returned non-array JSON"
msgstr "hyprctl monitors all вернул JSON не в виде массива"
#: src/backends/niri-backend.vala:44
#: src/backends/niri-backend.vala:47
msgid "niri configuration file was not found."
msgstr "Файл конфигурации niri не найден."
#: src/backends/niri-backend.vala:62
msgid "Add monitor.kdl to the niri configuration."
msgstr "Добавьте monitor.kdl в конфигурацию niri."
#: src/backends/niri-backend.vala:85
msgid "niri msg outputs returned non-object JSON"
msgstr "niri msg outputs вернул JSON не в виде объекта"
......@@ -67,73 +93,77 @@ msgstr "niri msg outputs вернул JSON не в виде объекта"
msgid "Built-in Display"
msgstr "Встроенный дисплей"
#: src/ui/displays-view.vala:68
#: src/ui/displays-view.vala:72
msgid "Failed to load monitors"
msgstr "Не удалось загрузить мониторы"
#: src/ui/displays-view.vala:77
#: src/ui/displays-view.vala:81
msgid "Monitor settings applied"
msgstr "Настройки мониторов применены"
#: src/ui/displays-view.vala:160
#: src/ui/displays-view.vala:165
msgid "Read-only backend"
msgstr "Режим только для чтения"
#: src/ui/displays-view.vala:161
#: src/ui/displays-view.vala:166
msgid "Applying monitor layouts is not supported by this backend."
msgstr "Применение раскладок мониторов не поддерживается этим бэкендом."
#: src/ui/displays-view.vala:171
#: src/ui/displays-view.vala:189
msgid "Monitor configuration connected"
msgstr "Конфигурация мониторов подключена"
#: src/ui/displays-view.vala:201
msgid "Mirror Displays"
msgstr "Зеркалировать мониторы"
#: src/ui/displays-view.vala:231 src/ui/monitor-settings-content.vala:88
#: src/ui/displays-view.vala:261 src/ui/monitor-settings-content.vala:88
#: src/ui/monitor-settings-content.vala:127
msgid "Resolution"
msgstr "Разрешение"
#: src/ui/displays-view.vala:277 src/ui/monitor-settings-content.vala:265
#: src/ui/displays-view.vala:307 src/ui/monitor-settings-content.vala:265
#: src/ui/monitor-settings-content.vala:291
msgid "Scale"
msgstr "Масштаб"
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "Normal"
msgstr "Обычный"
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "90 degrees"
msgstr "90 градусов"
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "180 degrees"
msgstr "180 градусов"
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "270 degrees"
msgstr "270 градусов"
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped"
msgstr "Отражённый"
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped 90 degrees"
msgstr "Отражённый 90 градусов"
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped 180 degrees"
msgstr "Отражённый 180 градусов"
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped 270 degrees"
msgstr "Отражённый 270 градусов"
#: src/ui/displays-view.vala:302 src/ui/monitor-settings-content.vala:315
#: src/ui/displays-view.vala:332 src/ui/monitor-settings-content.vala:315
msgid "Rotation"
msgstr "Поворот"
#: src/ui/displays-view.vala:335
#: src/ui/displays-view.vala:365
msgid "Primary Display"
msgstr "Основной дисплей"
......
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: tuner-displays\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-31 21:48+0300\n"
"POT-Creation-Date: 2026-06-15 11:58+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -21,7 +21,17 @@ msgstr ""
msgid "Displays"
msgstr ""
#: data/ui/displays-view.blp:15
#: data/ui/displays-view.blp:13 src/backends/hyprland-backend.vala:40
#: src/backends/hyprland-backend.vala:55 src/backends/niri-backend.vala:46
#: src/backends/niri-backend.vala:61
msgid "Monitor configuration is not connected"
msgstr ""
#: data/ui/displays-view.blp:18
msgid "Connect"
msgstr ""
#: data/ui/displays-view.blp:28
msgid "Details"
msgstr ""
......@@ -61,11 +71,27 @@ msgstr ""
msgid "No common mirror mode is available"
msgstr ""
#: src/backends/hyprland-backend.vala:39
#: src/backends/hyprland-backend.vala:41
msgid "Hyprland configuration file was not found."
msgstr ""
#: src/backends/hyprland-backend.vala:56
msgid "Add monitors.conf to the Hyprland configuration."
msgstr ""
#: src/backends/hyprland-backend.vala:80
msgid "hyprctl monitors all returned non-array JSON"
msgstr ""
#: src/backends/niri-backend.vala:44
#: src/backends/niri-backend.vala:47
msgid "niri configuration file was not found."
msgstr ""
#: src/backends/niri-backend.vala:62
msgid "Add monitor.kdl to the niri configuration."
msgstr ""
#: src/backends/niri-backend.vala:85
msgid "niri msg outputs returned non-object JSON"
msgstr ""
......@@ -73,73 +99,77 @@ msgstr ""
msgid "Built-in Display"
msgstr ""
#: src/ui/displays-view.vala:68
#: src/ui/displays-view.vala:72
msgid "Failed to load monitors"
msgstr ""
#: src/ui/displays-view.vala:77
#: src/ui/displays-view.vala:81
msgid "Monitor settings applied"
msgstr ""
#: src/ui/displays-view.vala:160
#: src/ui/displays-view.vala:165
msgid "Read-only backend"
msgstr ""
#: src/ui/displays-view.vala:161
#: src/ui/displays-view.vala:166
msgid "Applying monitor layouts is not supported by this backend."
msgstr ""
#: src/ui/displays-view.vala:171
#: src/ui/displays-view.vala:189
msgid "Monitor configuration connected"
msgstr ""
#: src/ui/displays-view.vala:201
msgid "Mirror Displays"
msgstr ""
#: src/ui/displays-view.vala:231 src/ui/monitor-settings-content.vala:88
#: src/ui/displays-view.vala:261 src/ui/monitor-settings-content.vala:88
#: src/ui/monitor-settings-content.vala:127
msgid "Resolution"
msgstr ""
#: src/ui/displays-view.vala:277 src/ui/monitor-settings-content.vala:265
#: src/ui/displays-view.vala:307 src/ui/monitor-settings-content.vala:265
#: src/ui/monitor-settings-content.vala:291
msgid "Scale"
msgstr ""
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "Normal"
msgstr ""
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "90 degrees"
msgstr ""
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "180 degrees"
msgstr ""
#: src/ui/displays-view.vala:295 src/ui/monitor-settings-content.vala:308
#: src/ui/displays-view.vala:325 src/ui/monitor-settings-content.vala:308
msgid "270 degrees"
msgstr ""
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped"
msgstr ""
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped 90 degrees"
msgstr ""
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped 180 degrees"
msgstr ""
#: src/ui/displays-view.vala:296 src/ui/monitor-settings-content.vala:309
#: src/ui/displays-view.vala:326 src/ui/monitor-settings-content.vala:309
msgid "Flipped 270 degrees"
msgstr ""
#: src/ui/displays-view.vala:302 src/ui/monitor-settings-content.vala:315
#: src/ui/displays-view.vala:332 src/ui/monitor-settings-content.vala:315
msgid "Rotation"
msgstr ""
#: src/ui/displays-view.vala:335
#: src/ui/displays-view.vala:365
msgid "Primary Display"
msgstr ""
......
......@@ -7,7 +7,23 @@ namespace TunerDisplays {
APPLY_FAILED
}
public enum ConfigIncludeState {
NOT_NEEDED,
INCLUDED,
NOT_INCLUDED,
MISSING_MAIN_CONFIG,
UNSUPPORTED
}
public class ConfigIncludeInfo : Object {
public ConfigIncludeState state { get; set; default = ConfigIncludeState.NOT_NEEDED; }
public string title { get; set; default = ""; }
public string subtitle { get; set; default = ""; }
}
public abstract class DisplayBackend : Object {
protected delegate bool ConfigIncludeMatcher(string content);
public abstract string id { get; }
public abstract string title { owned get; }
public abstract bool can_apply { get; }
......@@ -33,6 +49,14 @@ namespace TunerDisplays {
public abstract Gee.ArrayList<MonitorConfig> load() throws Error;
public abstract void apply(Gee.ArrayList<MonitorConfig> monitors) throws Error;
public virtual ConfigIncludeInfo config_include_info() {
return new ConfigIncludeInfo();
}
public virtual void include_monitor_config() throws Error {
throw new BackendError.UNSUPPORTED("Including monitor config is not supported by this backend");
}
protected static Json.Node backend_parse_json(string text) throws Error {
var parser = new Json.Parser();
parser.load_from_data(text);
......@@ -78,6 +102,86 @@ namespace TunerDisplays {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
protected static void backend_include_config_file(
string main_path,
string included_path,
string include_line,
string comment_prefix,
ConfigIncludeMatcher matcher
) throws Error {
backend_ensure_config_file(included_path);
string content = "";
if (FileUtils.test(main_path, FileTest.EXISTS))
FileUtils.get_contents(main_path, out content);
if (matcher(content))
return;
string updated;
if (backend_uncomment_config_include(content, comment_prefix, matcher, out updated)) {
FileUtils.set_contents(main_path, updated);
return;
}
var separator = content == "" || content.has_suffix("\n") ? "" : "\n";
FileUtils.set_contents(main_path, content + separator + include_line);
}
private static void backend_ensure_config_file(string path) throws Error {
DirUtils.create_with_parents(Path.get_dirname(path), 0755);
if (!FileUtils.test(path, FileTest.EXISTS))
FileUtils.set_contents(path, "");
}
private static bool backend_uncomment_config_include(
string content,
string comment_prefix,
ConfigIncludeMatcher matcher,
out string updated
) {
var builder = new StringBuilder();
var lines = content.split("\n");
var changed = false;
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (!changed && backend_uncomment_config_line(line, comment_prefix, matcher, out line))
changed = true;
if (i > 0)
builder.append_c('\n');
builder.append(line);
}
updated = builder.str;
return changed;
}
private static bool backend_uncomment_config_line(
string line,
string comment_prefix,
ConfigIncludeMatcher matcher,
out string uncommented
) {
uncommented = line;
var comment = line.index_of(comment_prefix);
if (comment < 0 || line.substring(0, comment).strip() != "")
return false;
var prefix = line.substring(0, comment);
var candidate = line.substring(comment + comment_prefix.length);
if (candidate.has_prefix(" "))
candidate = candidate.substring(1);
if (!matcher(candidate))
return false;
uncommented = prefix + candidate;
return true;
}
public static DisplayBackend create_for_session() {
var desktop = (Environment.get_variable("XDG_CURRENT_DESKTOP") ?? "").down();
var session = (Environment.get_variable("XDG_SESSION_DESKTOP") ?? "").down();
......
......@@ -29,6 +29,41 @@ namespace TunerDisplays {
public override bool supports_color_management { get { return true; } }
public override bool supports_hdr_metadata { get { return true; } }
public override ConfigIncludeInfo config_include_info() {
var main_path = main_config_path();
if (!FileUtils.test(main_path, FileTest.EXISTS)) {
return new ConfigIncludeInfo() {
state = ConfigIncludeState.MISSING_MAIN_CONFIG,
title = _("Monitor configuration is not connected"),
subtitle = _("Hyprland configuration file was not found.")
};
}
try {
string content;
if (FileUtils.get_contents(main_path, out content) && hyprland_config_includes_monitor_config(content))
return new ConfigIncludeInfo() { state = ConfigIncludeState.INCLUDED };
} catch (Error err) {
warning("Failed to check Hyprland monitor config include: %s", err.message);
}
return new ConfigIncludeInfo() {
state = ConfigIncludeState.NOT_INCLUDED,
title = _("Monitor configuration is not connected"),
subtitle = _("Add monitors.conf to the Hyprland configuration.")
};
}
public override void include_monitor_config() throws Error {
backend_include_config_file(
main_config_path(),
monitors_path(),
"source = ~/.config/hypr/monitors.conf\n",
"#",
hyprland_config_includes_monitor_config
);
}
public override Gee.ArrayList<MonitorConfig> load() throws Error {
var monitors = new Gee.ArrayList<MonitorConfig>();
var active = read_active_names();
......@@ -316,6 +351,33 @@ namespace TunerDisplays {
return Path.build_filename(Environment.get_user_config_dir(), "hypr", "monitors.conf");
}
private static string main_config_path() {
return Path.build_filename(Environment.get_user_config_dir(), "hypr", "hyprland.conf");
}
private static bool hyprland_config_includes_monitor_config(string content) {
foreach (var line in content.split("\n")) {
var trimmed = strip_hyprland_comment(line).strip();
if (!trimmed.has_prefix("source"))
continue;
var equals = trimmed.index_of("=");
if (equals < 0)
continue;
var path = trimmed.substring(equals + 1).strip();
if (path == "~/.config/hypr/monitors.conf" || path == monitors_path())
return true;
}
return false;
}
private static string strip_hyprland_comment(string line) {
var index = line.index_of("#");
return index >= 0 ? line.substring(0, index) : line;
}
private static DisplayMode? parse_mode(string value) {
var cleaned = value.replace("Hz", "");
var parts = cleaned.split("@");
......
......@@ -35,6 +35,41 @@ namespace TunerDisplays {
public override bool supports_backdrop_color { get { return true; } }
public override bool supports_hot_corners { get { return true; } }
public override ConfigIncludeInfo config_include_info() {
var main_path = main_config_path();
if (!FileUtils.test(main_path, FileTest.EXISTS)) {
return new ConfigIncludeInfo() {
state = ConfigIncludeState.MISSING_MAIN_CONFIG,
title = _("Monitor configuration is not connected"),
subtitle = _("niri configuration file was not found.")
};
}
try {
string content;
if (FileUtils.get_contents(main_path, out content) && niri_config_includes_monitor_config(content))
return new ConfigIncludeInfo() { state = ConfigIncludeState.INCLUDED };
} catch (Error err) {
warning("Failed to check niri monitor config include: %s", err.message);
}
return new ConfigIncludeInfo() {
state = ConfigIncludeState.NOT_INCLUDED,
title = _("Monitor configuration is not connected"),
subtitle = _("Add monitor.kdl to the niri configuration.")
};
}
public override void include_monitor_config() throws Error {
backend_include_config_file(
main_config_path(),
monitors_path(),
"include \"monitor.kdl\"\n",
"//",
niri_config_includes_monitor_config
);
}
public override Gee.ArrayList<MonitorConfig> load() throws Error {
var monitors = new Gee.ArrayList<MonitorConfig>();
var saved = read_saved_monitors();
......@@ -273,6 +308,28 @@ namespace TunerDisplays {
return Path.build_filename(Environment.get_user_config_dir(), "niri", "monitor.kdl");
}
private static string main_config_path() {
return Path.build_filename(Environment.get_user_config_dir(), "niri", "config.kdl");
}
private static bool niri_config_includes_monitor_config(string content) {
foreach (var line in content.split("\n")) {
var trimmed = strip_comment(line).strip();
if (!trimmed.has_prefix("include "))
continue;
var path = parse_quoted(trimmed);
if (path == "monitor.kdl"
|| path == "./monitor.kdl"
|| path == "~/.config/niri/monitor.kdl"
|| path == monitors_path()) {
return true;
}
}
return false;
}
private static void append_hot_corners(StringBuilder builder, string value) {
if (value == "")
return;
......
......@@ -12,6 +12,7 @@ sources = files(
'backends/niri-backend.vala',
'core/display-model.vala',
'core/shell-command.vala',
'ui/config-include-validator.vala',
'ui/displays-view.vala',
'ui/monitor-layout.vala',
'ui/monitor-row.vala',
......
namespace TunerDisplays {
public class ConfigIncludeBinding : Tuner.Binding {
private DisplayBackend backend;
public ConfigIncludeBinding(DisplayBackend backend) {
this.backend = backend;
}
public ConfigIncludeInfo info() {
return backend.config_include_info();
}
public void connect_config() throws Error {
backend.include_monitor_config();
emit_changed();
}
public override Type expected_type { get { return typeof(bool); } }
public override bool get_value(ref Value value) {
value = info().state == ConfigIncludeState.NOT_INCLUDED;
return true;
}
public override void set_value(Value value) {
}
}
public class ConfigIncludeValidator : Tuner.Validator {
public override void apply(Tuner.Binding binding, Gtk.Widget native_widget) {
var include_binding = binding as ConfigIncludeBinding;
var row = native_widget as Adw.ActionRow;
if (include_binding == null || row == null)
return;
update(include_binding, row);
include_binding.changed.connect(() => update(include_binding, row));
}
private static void update(ConfigIncludeBinding binding, Adw.ActionRow row) {
var info = binding.info();
var visible = info.state == ConfigIncludeState.NOT_INCLUDED
|| info.state == ConfigIncludeState.MISSING_MAIN_CONFIG;
row.visible = visible;
var group = row.get_ancestor(typeof(Adw.PreferencesGroup));
if (group != null)
group.visible = visible;
if (!visible)
return;
row.title = info.title;
row.subtitle = info.subtitle;
}
}
}
......@@ -11,8 +11,11 @@ namespace TunerDisplays {
private MonitorSettingsContent? single_monitor_content;
private MonitorLayout layout;
private Adw.PreferencesRow layout_row;
private ConfigIncludeBinding config_include_binding;
private DBusConnection? session_bus;
private uint monitors_changed_id;
[GtkChild] private unowned Adw.ActionRow config_include_row;
[GtkChild] private unowned Gtk.Button config_include_button;
[GtkChild] private unowned Adw.PreferencesGroup monitors_group;
[GtkChild] private unowned Adw.PreferencesGroup layout_group;
[GtkChild] private unowned Adw.PreferencesGroup status_group;
......@@ -23,6 +26,7 @@ namespace TunerDisplays {
construct {
backend = DisplayBackend.create_for_session();
config_include_binding = new ConfigIncludeBinding(backend);
layout_row = new Adw.PreferencesRow() {
activatable = false,
......@@ -40,6 +44,9 @@ namespace TunerDisplays {
layout.layout_changed.connect(sync_rows);
layout_row.child = layout;
layout_group.add(layout_row);
config_include_button.clicked.connect(connect_monitor_config);
new ConfigIncludeValidator().apply(config_include_binding, config_include_row);
config_include_binding.changed.connect(sync_config_include_button);
subscribe_monitor_changes();
reload();
......@@ -133,6 +140,7 @@ namespace TunerDisplays {
clear_status_rows();
clear_mirror_settings_rows();
clear_single_monitor_settings();
config_include_binding.emit_changed();
sync_layout_visibility();
sync_group_visibility();
......@@ -163,6 +171,20 @@ namespace TunerDisplays {
}
}
private void sync_config_include_button() {
config_include_button.visible = config_include_binding.info().state == ConfigIncludeState.NOT_INCLUDED;
}
private void connect_monitor_config() {
try {
config_include_binding.connect_config();
Tuner.toast(_("Monitor configuration connected"));
reload();
} catch (Error err) {
Tuner.toast(err.message);
}
}
private void add_gnome_mirror_row() {
if (!backend.supports_global_mirroring || monitors.size <= 1)
return;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment