Skip to content

Commit 538ee02

Browse files
committed
feat: unified logging, config persistence, label layout, localization
## Logging & Events - Add Logger class (Manager namespace) with OnLog event broadcast - Managed.cs bridges Logger → Android logcat; Logger is platform-agnostic - Replace all AndroidUtils.Error/Info/Warn → Logger calls across codebase - Add [CallerFilePath]/[CallerLineNumber] for automatic msg source location ## Config Persistence - Add ModManagerConfig class for UI scale, scrollbar, mod enabled states - Config saved to manager DLL directory (modmanager_config.json) - Auto-enable mods from config on scan; auto-cleanup stale mod entries - Settings tab "Save Settings" button; auto-save on mod toggle ## Inspector & Label Layout - Add LabelSide (Left/Top/Right) attribute + ModSettingLabelSideAttribute - Default Top layout with label above control - Left side: fixed-width label column, AlignTextToFramePadding for Y alignment - Long controls constrained to N px from edge (Top/Right use Left label margin) - Serialize only inspector-visible fields (ignore IModPlugin properties) ## Refactoring - Split ModInspector → ModInspector.cs + .Build.cs + .Draw.cs (partial) - Split ModManagerUI → ModManagerUI.cs + .Panels.cs (partial) - Remove LoadPriority from IModPlugin/ModEntry/ModLoader ## Localization - Add L10n helper + Localization.resx/.en.resx with 40+ keys - Localize all user-facing strings in Manager, Runtime, Inspector - Exclude ImGuiRender.cs (single-file debug renderer) ## Footer - Pinned "N mods | loaded: N [Save]" bar always visible below mod table
1 parent be602b8 commit 538ee02

33 files changed

Lines changed: 1328 additions & 1593 deletions
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System.Linq.Expressions;
2+
using System.Reflection;
3+
4+
namespace StArray.ModManager.Inspector;
5+
6+
partial class ModInspector
7+
{
8+
private static Entry[] BuildEntries(Type type)
9+
{
10+
var list = new List<Entry>();
11+
12+
foreach (var f in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
13+
{
14+
if (f.GetCustomAttribute<ModSettingIgnoreAttribute>() != null) continue;
15+
if (type.GetEvent(f.Name) != null) continue;
16+
var attrs = f.GetCustomAttributes().ToArray();
17+
list.Add(MakeEntry(f.Name, f.FieldType,
18+
attrs.OfType<ModSettingLabelAttribute>().FirstOrDefault(),
19+
attrs.OfType<ModSettingRangeAttribute>().FirstOrDefault(),
20+
t => f.GetValue(t), (t, v) => f.SetValue(t, v),
21+
attrs.OfType<ModSettingLabelSideAttribute>().FirstOrDefault(), attrs));
22+
}
23+
24+
foreach (var f in type.GetFields(BindingFlags.Public | BindingFlags.Static))
25+
{
26+
if (f.GetCustomAttribute<ModSettingIgnoreAttribute>() != null) continue;
27+
if (type.GetEvent(f.Name) != null) continue;
28+
list.Add(MakeEntry($"[S] {f.Name}", f.FieldType,
29+
f.GetCustomAttribute<ModSettingLabelAttribute>(),
30+
f.GetCustomAttribute<ModSettingRangeAttribute>(),
31+
_ => f.GetValue(null), (_, v) => f.SetValue(null, v)));
32+
}
33+
34+
foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
35+
{
36+
if (p.GetCustomAttribute<ModSettingIgnoreAttribute>() != null) continue;
37+
if (p.GetIndexParameters().Length > 0 || !p.CanRead || !p.CanWrite) continue;
38+
var get = CompileGet(p, type);
39+
var set = CompileSet(p, type);
40+
if (get == null || set == null) continue;
41+
list.Add(MakeEntry(p.Name, p.PropertyType,
42+
p.GetCustomAttribute<ModSettingLabelAttribute>(),
43+
p.GetCustomAttribute<ModSettingRangeAttribute>(), get, set));
44+
}
45+
46+
foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Static))
47+
{
48+
if (p.GetCustomAttribute<ModSettingIgnoreAttribute>() != null) continue;
49+
if (p.GetIndexParameters().Length > 0 || !p.CanRead || !p.CanWrite) continue;
50+
var get = CompileStaticGet(p);
51+
var set = CompileStaticSet(p);
52+
if (get == null || set == null) continue;
53+
list.Add(MakeEntry($"[S] {p.Name}", p.PropertyType,
54+
p.GetCustomAttribute<ModSettingLabelAttribute>(),
55+
p.GetCustomAttribute<ModSettingRangeAttribute>(), get, set));
56+
}
57+
58+
return list.ToArray();
59+
}
60+
61+
private static Entry MakeEntry(string name, Type vt,
62+
ModSettingLabelAttribute? la, ModSettingRangeAttribute? ra,
63+
Func<object, object?> get, Action<object, object?> set,
64+
ModSettingLabelSideAttribute? ls = null, Attribute[]? extraAttrs = null)
65+
{
66+
var json = extraAttrs?.OfType<ModSettingJsonAttribute>().FirstOrDefault();
67+
return new(la?.Label ?? name, vt, get, set,
68+
ra?.Min ?? 0f, ra?.Max ?? 0f, ra != null, json?.Lines ?? 0,
69+
ra?.Mins, ra?.Maxs, ls?.Side ?? LabelSide.Top);
70+
}
71+
72+
private static Func<object, object?>? CompileGet(PropertyInfo p, Type owner)
73+
{
74+
try
75+
{
76+
var t = Expression.Parameter(typeof(object));
77+
var access = Expression.Property(Expression.Convert(t, owner), p);
78+
return Expression.Lambda<Func<object, object?>>(Expression.Convert(access, typeof(object)), t).Compile();
79+
}
80+
catch { return null; }
81+
}
82+
83+
private static Action<object, object?>? CompileSet(PropertyInfo p, Type owner)
84+
{
85+
try
86+
{
87+
var t = Expression.Parameter(typeof(object));
88+
var v = Expression.Parameter(typeof(object));
89+
var assign = Expression.Assign(
90+
Expression.Property(Expression.Convert(t, owner), p),
91+
Expression.Convert(v, p.PropertyType));
92+
return Expression.Lambda<Action<object, object?>>(assign, t, v).Compile();
93+
}
94+
catch { return null; }
95+
}
96+
97+
private static Func<object, object?>? CompileStaticGet(PropertyInfo p)
98+
{
99+
try
100+
{
101+
var access = Expression.Property(null, p);
102+
return Expression.Lambda<Func<object, object?>>(Expression.Convert(access, typeof(object))).Compile();
103+
}
104+
catch { return null; }
105+
}
106+
107+
private static Action<object, object?>? CompileStaticSet(PropertyInfo p)
108+
{
109+
try
110+
{
111+
var v = Expression.Parameter(typeof(object));
112+
var assign = Expression.Assign(
113+
Expression.Property(null, p),
114+
Expression.Convert(v, p.PropertyType));
115+
return Expression.Lambda<Action<object, object?>>(assign, v).Compile();
116+
}
117+
catch { return null; }
118+
}
119+
120+
private static bool IsNumeric(Type t) =>
121+
t == typeof(int) || t == typeof(long) || t == typeof(float) || t == typeof(double)
122+
|| t == typeof(short) || t == typeof(byte) || t == typeof(decimal);
123+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
using StArray.ModManager.Resources;
2+
using System.Collections.Concurrent;
3+
using System.Numerics;
4+
using ImGuiNET;
5+
using StArray.ModManager.Runtime;
6+
7+
namespace StArray.ModManager.Inspector;
8+
9+
partial class ModInspector
10+
{
11+
private static string WL(Entry e) => $"##{e.Label}";
12+
private static void Pre(Entry e)
13+
{
14+
if (e.Side == LabelSide.Top)
15+
{
16+
ImGui.SetCursorPosX(_leftMargin);
17+
ImGui.Text(e.Label);
18+
ImGui.SetNextItemWidth(_controlWidth);
19+
}
20+
else if (e.Side == LabelSide.Left)
21+
{
22+
ImGui.AlignTextToFramePadding();
23+
var pos = ImGui.GetCursorPosX();
24+
ImGui.Text(e.Label);
25+
ImGui.SameLine(pos + _maxLabelWidth + ImGui.GetStyle().ItemInnerSpacing.X);
26+
}
27+
else if (e.Side == LabelSide.Right)
28+
{
29+
ImGui.SetNextItemWidth(_controlWidth);
30+
}
31+
}
32+
private static void Post(Entry e)
33+
{
34+
if (e.Side == LabelSide.Right) { ImGui.SameLine(); ImGui.Text(e.Label); }
35+
}
36+
37+
private static bool TryDrawField(Entry e, object? value, out object? newValue)
38+
{
39+
newValue = value;
40+
var type = e.ValueType;
41+
42+
// ---- 基本类型 ----
43+
if (type == typeof(bool) && value is bool b) { Pre(e); var v = b; if (Bool(WL(e), ref v)) { newValue = v; Post(e); return true; } Post(e); return false; }
44+
if (type == typeof(int) && value is int i) { Pre(e); var v = i; bool ch = e.HasRange ? SliderInt(WL(e), ref v, (int)e.RangeMin, (int)e.RangeMax) : Int(WL(e), ref v); if (ch) { newValue = v; Post(e); return true; } Post(e); return false; }
45+
if (type == typeof(long) && value is long l) { Pre(e); var v = l; if (Long(WL(e), ref v)) { newValue = v; Post(e); return true; } Post(e); return false; }
46+
if (type == typeof(float) && value is float f) { Pre(e); var v = f; bool ch = e.HasRange ? SliderFloat(WL(e), ref v, e.RangeMin, e.RangeMax) : Float(WL(e), ref v); if (ch) { newValue = v; Post(e); return true; } Post(e); return false; }
47+
if (type == typeof(double) && value is double dv) { Pre(e); var v = dv; if (Double(WL(e), ref v)) { newValue = v; Post(e); return true; } Post(e); return false; }
48+
if (type.IsEnum) { Pre(e); var v = value ?? Activator.CreateInstance(type); var r = TryDrawEnum(WL(e), type, v, out newValue); Post(e); return r; }
49+
50+
if (type == typeof(string) && value is string s)
51+
{
52+
var v = s ?? "";
53+
if (e.JsonLines > 0)
54+
{
55+
if (ImGui.TreeNode(e.Label))
56+
{
57+
var key = $"str_{e.Label}";
58+
var cur = JsonEditCache.GetOrAdd(key, v);
59+
if (cur != v && !ImGui.IsItemActive()) cur = JsonEditCache[key] = v;
60+
61+
var changed = ImGui.InputTextMultiline($"##{key}", ref cur, 65536,
62+
new Vector2(Math.Max(300, ImGui.GetContentRegionAvail().X - 20), ImGui.GetTextLineHeight() * e.JsonLines));
63+
64+
if (changed)
65+
{
66+
JsonEditCache[key] = cur;
67+
try
68+
{
69+
System.Text.Json.JsonDocument.Parse(cur);
70+
newValue = cur;
71+
ImGui.TreePop();
72+
return true;
73+
}
74+
catch (Exception ex)
75+
{
76+
ImGui.TextColored(new Vector4(1, 0.4f, 0.4f, 1), $"JSON: {ex.Message}");
77+
}
78+
}
79+
ImGui.TreePop();
80+
}
81+
return false;
82+
}
83+
Pre(e);
84+
if (Text(WL(e), ref v)) { newValue = v; Post(e); return true; }
85+
Post(e);
86+
return false;
87+
}
88+
89+
if (type == typeof(Vector2) && value is Vector2 v2)
90+
{
91+
Pre(e);
92+
var v = v2;
93+
bool ch;
94+
if (e.VecMins != null && e.VecMaxs != null)
95+
ch = ImGui.SliderFloat2(WL(e), ref v, e.VecMins[0], e.VecMaxs[0]);
96+
else if (e.HasRange)
97+
ch = ImGui.SliderFloat2(WL(e), ref v, e.RangeMin, e.RangeMax);
98+
else
99+
ch = Vec2(WL(e), ref v);
100+
if (ch) { newValue = v; Post(e); return true; }
101+
Post(e);
102+
return false;
103+
}
104+
if (type == typeof(Vector3) && value is Vector3 v3)
105+
{
106+
Pre(e);
107+
var v = v3;
108+
bool ch;
109+
if (e.VecMins != null && e.VecMaxs != null)
110+
ch = ImGui.SliderFloat3(WL(e), ref v, e.VecMins[0], e.VecMaxs[0]);
111+
else if (e.HasRange)
112+
ch = ImGui.SliderFloat3(WL(e), ref v, e.RangeMin, e.RangeMax);
113+
else
114+
ch = Vec3(WL(e), ref v);
115+
if (ch) { newValue = v; Post(e); return true; }
116+
Post(e);
117+
return false;
118+
}
119+
if (type == typeof(Vector4) && value is Vector4 v4)
120+
{
121+
Pre(e);
122+
var v = v4;
123+
bool ch;
124+
if (e.VecMins != null && e.VecMaxs != null)
125+
ch = ImGui.SliderFloat4(WL(e), ref v, e.VecMins[0], e.VecMaxs[0]);
126+
else if (e.HasRange)
127+
ch = ImGui.SliderFloat4(WL(e), ref v, e.RangeMin, e.RangeMax);
128+
else
129+
ch = Vec4(WL(e), ref v);
130+
if (ch) { newValue = v; Post(e); return true; }
131+
Post(e);
132+
return false;
133+
}
134+
135+
if (value != null && type.IsGenericType && type.Name.StartsWith("ValueTuple`"))
136+
{
137+
var args = type.GetGenericArguments();
138+
if (args is [Type t1, Type t2] && IsNumeric(t1) && IsNumeric(t2))
139+
{
140+
var x = Convert.ToSingle(type.GetField("Item1")!.GetValue(value));
141+
var y = Convert.ToSingle(type.GetField("Item2")!.GetValue(value));
142+
var v = new Vector2(x, y);
143+
Pre(e);
144+
if (Vec2(WL(e), ref v))
145+
{
146+
type.GetField("Item1")!.SetValue(value, Convert.ChangeType(v.X, t1));
147+
type.GetField("Item2")!.SetValue(value, Convert.ChangeType(v.Y, t2));
148+
newValue = value; Post(e); return true;
149+
}
150+
Post(e);
151+
return false;
152+
}
153+
if (args is [Type t1b, Type t2b, Type t3b] && IsNumeric(t1b) && IsNumeric(t2b) && IsNumeric(t3b))
154+
{
155+
var x = Convert.ToSingle(type.GetField("Item1")!.GetValue(value));
156+
var y = Convert.ToSingle(type.GetField("Item2")!.GetValue(value));
157+
var z = Convert.ToSingle(type.GetField("Item3")!.GetValue(value));
158+
var v = new Vector3(x, y, z);
159+
Pre(e);
160+
if (Vec3(WL(e), ref v)) { newValue = value; Post(e); return true; }
161+
Post(e);
162+
return false;
163+
}
164+
}
165+
166+
if (CustomDrawers.TryGetValue(type, out var d)) { d.DynamicInvoke(value); return false; }
167+
168+
if (value is IModSettingCustomDraw cd)
169+
{
170+
if (ImGui.TreeNode(e.Label)) { Draw(value); ImGui.Separator(); cd.DrawInspector(); ImGui.TreePop(); }
171+
return type.IsValueType;
172+
}
173+
174+
if (value != null && (value is System.Collections.IEnumerable && !(value is string)))
175+
{
176+
return DrawJsonEditor(e, type, value, out newValue);
177+
}
178+
179+
if (!type.IsPrimitive && type != typeof(string) && !type.IsEnum
180+
&& type != typeof(Vector2) && type != typeof(Vector3) && type != typeof(Vector4)
181+
&& value != null && !type.IsGenericType)
182+
{
183+
if (ImGui.TreeNode(e.Label)) { Draw(value); ImGui.TreePop(); }
184+
return type.IsValueType;
185+
}
186+
187+
if (value != null && !type.IsPrimitive && type != typeof(string))
188+
{
189+
return DrawJsonEditor(e, type, value, out newValue);
190+
}
191+
192+
ImGui.TextDisabled(value == null ? $"{e.Label}: null" : $"{e.Label}: {value}");
193+
return false;
194+
}
195+
196+
private static bool TryDrawEnum(string label, Type type, object value, out object? newValue)
197+
{
198+
var names = System.Enum.GetNames(type);
199+
var idx = Math.Max(0, Array.IndexOf(names, value.ToString() ?? ""));
200+
if (ImGui.Combo(label, ref idx, names, names.Length))
201+
{
202+
newValue = System.Enum.Parse(type, names[idx]);
203+
return true;
204+
}
205+
newValue = value;
206+
return false;
207+
}
208+
209+
private static readonly ConcurrentDictionary<string, string> JsonEditCache = new();
210+
211+
private static bool DrawJsonEditor(Entry e, Type type, object value, out object? newValue)
212+
{
213+
newValue = value;
214+
var key = $"{type.FullName}_{e.Label}";
215+
216+
if (!ImGui.TreeNode(e.Label)) return type.IsValueType;
217+
218+
var currentJson = System.Text.Json.JsonSerializer.Serialize(value,
219+
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
220+
var editText = JsonEditCache.GetOrAdd(key, currentJson);
221+
222+
if (editText != currentJson && !ImGui.IsItemActive())
223+
editText = JsonEditCache[key] = currentJson;
224+
225+
var lines = Math.Max(3, editText.Split('\n').Length);
226+
var width = Math.Max(300, ImGui.GetContentRegionAvail().X - 20);
227+
var changed = ImGui.InputTextMultiline($"##json_{key}", ref editText, 65536,
228+
new Vector2(width, ImGui.GetTextLineHeight() * Math.Min(lines, 12)));
229+
230+
if (changed)
231+
{
232+
JsonEditCache[key] = editText;
233+
try
234+
{
235+
var deserialized = System.Text.Json.JsonSerializer.Deserialize(editText, type,
236+
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
237+
if (deserialized != null)
238+
{
239+
newValue = deserialized;
240+
ImGui.TreePop();
241+
return true;
242+
}
243+
ImGui.TextColored(new Vector4(1, 0.4f, 0.4f, 1), L10n.Get("Inspector_DeserializeNull"));
244+
}
245+
catch (Exception ex)
246+
{
247+
ImGui.TextColored(new Vector4(1, 0.4f, 0.4f, 1), L10n.Get("Inspector_JsonError", ex.Message));
248+
}
249+
}
250+
251+
ImGui.TreePop();
252+
return type.IsValueType;
253+
}
254+
255+
public enum LabelSide
256+
{
257+
Top,Left,Right
258+
}
259+
}

0 commit comments

Comments
 (0)