• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 20737823009

06 Jan 2026 04:16AM UTC coverage: 71.324% (-2.6%) from 73.964%
20737823009

push

github

tyevco
feat(localization): Add RTL layout support and advanced localization features

- Add TextDirection enum for distinguishing LTR/RTL text flow
- Add IsRightToLeft property and RTL locale constants to Locale struct
- Add MirrorForRtl property to UIRect for automatic layout mirroring
- Update UILayoutSystem to handle RTL-aware anchor and flexbox layouts
- Add text shaping infrastructure with ITextShaper interface
- Implement ArabicTextShaper for contextual letter forms
- Add BidirectionalTextShaper for mixed LTR/RTL text handling
- Add ComplexScriptInfo for Thai, Hindi, Bengali, Tamil shaping info
- Add CsvStringSource for CSV import/export translator workflows
- Add comprehensive unit tests for all new functionality

Closes #635

5865 of 7851 branches covered (74.7%)

Branch coverage included in aggregate %.

26 of 764 new or added lines in 9 files covered. (3.4%)

452 existing lines in 6 files now uncovered.

37040 of 52304 relevant lines covered (70.82%)

1.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/KeenEyes.Localization/CsvStringSource.cs
1
using System.Text;
2

3
namespace KeenEyes.Localization;
4

5
/// <summary>
6
/// A string source that loads translations from CSV files for spreadsheet workflows.
7
/// </summary>
8
/// <remarks>
9
/// <para>
10
/// CSV files provide an easy way for translators to work with translations using
11
/// spreadsheet applications like Excel or Google Sheets.
12
/// </para>
13
/// <para>
14
/// Expected CSV format:
15
/// </para>
16
/// <code>
17
/// key,en,es,ar,ja
18
/// menu.start,Start Game,Iniciar Juego,ابدأ اللعبة,ゲームを始める
19
/// menu.quit,Quit,Salir,خروج,終了
20
/// </code>
21
/// <para>
22
/// The first column must be "key". Subsequent columns are locale codes.
23
/// Values containing commas, quotes, or newlines should be quoted.
24
/// </para>
25
/// </remarks>
26
/// <example>
27
/// <code>
28
/// // Load from file
29
/// var source = CsvStringSource.FromFile("translations.csv");
30
/// localization.AddSource(source);
31
///
32
/// // Export for translators
33
/// CsvStringSource.Export("translations_export.csv", existingSource, [Locale.EnglishUS, Locale.Spanish]);
34
/// </code>
35
/// </example>
36
public sealed class CsvStringSource : IStringSource
37
{
NEW
38
    private readonly Dictionary<Locale, Dictionary<string, string>> translations = [];
×
NEW
39
    private readonly List<Locale> localeOrder = [];
×
40

NEW
41
    private CsvStringSource()
×
NEW
42
    {
×
NEW
43
    }
×
44

45
    /// <inheritdoc />
NEW
46
    public IEnumerable<Locale> SupportedLocales => translations.Keys;
×
47

48
    /// <summary>
49
    /// Gets the locales in the order they appear in the CSV header.
50
    /// </summary>
NEW
51
    public IReadOnlyList<Locale> LocaleOrder => localeOrder;
×
52

53
    /// <inheritdoc />
54
    public bool TryGetString(Locale locale, string key, out string? value)
NEW
55
    {
×
NEW
56
        if (translations.TryGetValue(locale, out var strings) &&
×
NEW
57
            strings.TryGetValue(key, out value))
×
NEW
58
        {
×
NEW
59
            return true;
×
60
        }
61

NEW
62
        value = null;
×
NEW
63
        return false;
×
NEW
64
    }
×
65

66
    /// <inheritdoc />
67
    public IEnumerable<string> GetKeys(Locale locale)
NEW
68
    {
×
NEW
69
        if (translations.TryGetValue(locale, out var strings))
×
NEW
70
        {
×
NEW
71
            return strings.Keys;
×
72
        }
73

NEW
74
        return [];
×
NEW
75
    }
×
76

77
    /// <inheritdoc />
NEW
78
    public bool HasLocale(Locale locale) => translations.ContainsKey(locale);
×
79

80
    /// <summary>
81
    /// Gets all keys in this source across all locales.
82
    /// </summary>
83
    public IEnumerable<string> AllKeys
84
    {
85
        get
NEW
86
        {
×
NEW
87
            var keys = new HashSet<string>();
×
NEW
88
            foreach (var localeStrings in translations.Values)
×
NEW
89
            {
×
NEW
90
                foreach (var key in localeStrings.Keys)
×
NEW
91
                {
×
NEW
92
                    keys.Add(key);
×
NEW
93
                }
×
NEW
94
            }
×
NEW
95
            return keys;
×
NEW
96
        }
×
97
    }
98

99
    /// <summary>
100
    /// Creates a string source from a CSV string.
101
    /// </summary>
102
    /// <param name="csv">The CSV content.</param>
103
    /// <returns>A new <see cref="CsvStringSource"/> containing the parsed translations.</returns>
104
    /// <exception cref="FormatException">Thrown if the CSV format is invalid.</exception>
105
    public static CsvStringSource FromString(string csv)
NEW
106
    {
×
NEW
107
        ArgumentNullException.ThrowIfNull(csv);
×
108

NEW
109
        var source = new CsvStringSource();
×
NEW
110
        var lines = ParseCsvLines(csv);
×
111

NEW
112
        if (lines.Count == 0)
×
NEW
113
        {
×
NEW
114
            return source;
×
115
        }
116

117
        // Parse header row
NEW
118
        var header = lines[0];
×
NEW
119
        if (header.Count == 0 || !header[0].Equals("key", StringComparison.OrdinalIgnoreCase))
×
NEW
120
        {
×
NEW
121
            throw new FormatException("CSV header must start with 'key' column");
×
122
        }
123

124
        // Extract locales from header
NEW
125
        var locales = new List<Locale>();
×
NEW
126
        for (int i = 1; i < header.Count; i++)
×
NEW
127
        {
×
NEW
128
            var locale = new Locale(header[i].Trim());
×
NEW
129
            locales.Add(locale);
×
NEW
130
            source.localeOrder.Add(locale);
×
NEW
131
            source.translations[locale] = [];
×
NEW
132
        }
×
133

134
        // Parse data rows
NEW
135
        for (int rowIndex = 1; rowIndex < lines.Count; rowIndex++)
×
NEW
136
        {
×
NEW
137
            var row = lines[rowIndex];
×
NEW
138
            if (row.Count == 0 || string.IsNullOrWhiteSpace(row[0]))
×
NEW
139
            {
×
NEW
140
                continue; // Skip empty rows
×
141
            }
142

NEW
143
            var key = row[0].Trim();
×
144

NEW
145
            for (int colIndex = 1; colIndex < row.Count && colIndex <= locales.Count; colIndex++)
×
NEW
146
            {
×
NEW
147
                var locale = locales[colIndex - 1];
×
NEW
148
                var value = row[colIndex];
×
149

NEW
150
                if (!string.IsNullOrEmpty(value))
×
NEW
151
                {
×
NEW
152
                    source.translations[locale][key] = value;
×
NEW
153
                }
×
NEW
154
            }
×
NEW
155
        }
×
156

NEW
157
        return source;
×
NEW
158
    }
×
159

160
    /// <summary>
161
    /// Creates a string source by loading a CSV file.
162
    /// </summary>
163
    /// <param name="path">The path to the CSV file.</param>
164
    /// <returns>A new <see cref="CsvStringSource"/> containing the parsed translations.</returns>
165
    /// <exception cref="FileNotFoundException">Thrown if the file does not exist.</exception>
166
    /// <exception cref="FormatException">Thrown if the CSV format is invalid.</exception>
167
    public static CsvStringSource FromFile(string path)
NEW
168
    {
×
NEW
169
        ArgumentNullException.ThrowIfNull(path);
×
170

NEW
171
        var csv = File.ReadAllText(path, Encoding.UTF8);
×
NEW
172
        return FromString(csv);
×
NEW
173
    }
×
174

175
    /// <summary>
176
    /// Creates a string source by loading a CSV file asynchronously.
177
    /// </summary>
178
    /// <param name="path">The path to the CSV file.</param>
179
    /// <param name="cancellationToken">A cancellation token.</param>
180
    /// <returns>A new <see cref="CsvStringSource"/> containing the parsed translations.</returns>
181
    /// <exception cref="FileNotFoundException">Thrown if the file does not exist.</exception>
182
    /// <exception cref="FormatException">Thrown if the CSV format is invalid.</exception>
183
    public static async Task<CsvStringSource> FromFileAsync(
184
        string path,
185
        CancellationToken cancellationToken = default)
NEW
186
    {
×
NEW
187
        ArgumentNullException.ThrowIfNull(path);
×
188

NEW
189
        var csv = await File.ReadAllTextAsync(path, Encoding.UTF8, cancellationToken);
×
NEW
190
        return FromString(csv);
×
NEW
191
    }
×
192

193
    /// <summary>
194
    /// Creates a string source from a stream.
195
    /// </summary>
196
    /// <param name="stream">The stream containing CSV data.</param>
197
    /// <returns>A new <see cref="CsvStringSource"/> containing the parsed translations.</returns>
198
    /// <exception cref="FormatException">Thrown if the CSV format is invalid.</exception>
199
    public static CsvStringSource FromStream(Stream stream)
NEW
200
    {
×
NEW
201
        ArgumentNullException.ThrowIfNull(stream);
×
202

NEW
203
        using var reader = new StreamReader(stream, Encoding.UTF8);
×
NEW
204
        var csv = reader.ReadToEnd();
×
NEW
205
        return FromString(csv);
×
NEW
206
    }
×
207

208
    /// <summary>
209
    /// Exports translations to CSV format.
210
    /// </summary>
211
    /// <param name="source">The string source to export.</param>
212
    /// <param name="locales">The locales to include in the export.</param>
213
    /// <returns>The CSV content as a string.</returns>
214
    public static string ToCsv(IStringSource source, IEnumerable<Locale> locales)
NEW
215
    {
×
NEW
216
        ArgumentNullException.ThrowIfNull(source);
×
NEW
217
        ArgumentNullException.ThrowIfNull(locales);
×
218

NEW
219
        var localeList = locales.ToList();
×
NEW
220
        var allKeys = new HashSet<string>();
×
221

NEW
222
        foreach (var locale in localeList)
×
NEW
223
        {
×
NEW
224
            foreach (var key in source.GetKeys(locale))
×
NEW
225
            {
×
NEW
226
                allKeys.Add(key);
×
NEW
227
            }
×
NEW
228
        }
×
229

NEW
230
        var sb = new StringBuilder();
×
231

232
        // Write header
NEW
233
        sb.Append("key");
×
NEW
234
        foreach (var locale in localeList)
×
NEW
235
        {
×
NEW
236
            sb.Append(',');
×
NEW
237
            sb.Append(locale.Code);
×
NEW
238
        }
×
NEW
239
        sb.AppendLine();
×
240

241
        // Write data rows
NEW
242
        foreach (var key in allKeys.OrderBy(k => k))
×
NEW
243
        {
×
NEW
244
            sb.Append(EscapeCsvValue(key));
×
245

NEW
246
            foreach (var locale in localeList)
×
NEW
247
            {
×
NEW
248
                sb.Append(',');
×
NEW
249
                if (source.TryGetString(locale, key, out var value) && value != null)
×
NEW
250
                {
×
NEW
251
                    sb.Append(EscapeCsvValue(value));
×
NEW
252
                }
×
NEW
253
            }
×
NEW
254
            sb.AppendLine();
×
NEW
255
        }
×
256

NEW
257
        return sb.ToString();
×
NEW
258
    }
×
259

260
    /// <summary>
261
    /// Exports translations to a CSV file.
262
    /// </summary>
263
    /// <param name="path">The file path to write to.</param>
264
    /// <param name="source">The string source to export.</param>
265
    /// <param name="locales">The locales to include in the export.</param>
266
    public static void Export(string path, IStringSource source, IEnumerable<Locale> locales)
NEW
267
    {
×
NEW
268
        var csv = ToCsv(source, locales);
×
NEW
269
        File.WriteAllText(path, csv, Encoding.UTF8);
×
NEW
270
    }
×
271

272
    /// <summary>
273
    /// Exports translations to a CSV file asynchronously.
274
    /// </summary>
275
    /// <param name="path">The file path to write to.</param>
276
    /// <param name="source">The string source to export.</param>
277
    /// <param name="locales">The locales to include in the export.</param>
278
    /// <param name="cancellationToken">A cancellation token.</param>
279
    public static async Task ExportAsync(
280
        string path,
281
        IStringSource source,
282
        IEnumerable<Locale> locales,
283
        CancellationToken cancellationToken = default)
NEW
284
    {
×
NEW
285
        var csv = ToCsv(source, locales);
×
NEW
286
        await File.WriteAllTextAsync(path, csv, Encoding.UTF8, cancellationToken);
×
NEW
287
    }
×
288

289
    /// <summary>
290
    /// Converts this source to CSV format.
291
    /// </summary>
292
    /// <returns>The CSV content as a string.</returns>
293
    public string ToCsv()
NEW
294
    {
×
NEW
295
        return ToCsv(this, localeOrder.Count > 0 ? localeOrder : SupportedLocales);
×
NEW
296
    }
×
297

298
    /// <summary>
299
    /// Merges translations from another CSV string.
300
    /// </summary>
301
    /// <param name="csv">The CSV content to merge.</param>
302
    /// <remarks>
303
    /// Existing keys will be overwritten with new values.
304
    /// </remarks>
305
    public void MergeFromString(string csv)
NEW
306
    {
×
NEW
307
        var other = FromString(csv);
×
308

NEW
309
        foreach (var locale in other.SupportedLocales)
×
NEW
310
        {
×
NEW
311
            if (!translations.ContainsKey(locale))
×
NEW
312
            {
×
NEW
313
                translations[locale] = [];
×
NEW
314
                localeOrder.Add(locale);
×
NEW
315
            }
×
316

NEW
317
            foreach (var key in other.GetKeys(locale))
×
NEW
318
            {
×
NEW
319
                if (other.TryGetString(locale, key, out var value) && value != null)
×
NEW
320
                {
×
NEW
321
                    translations[locale][key] = value;
×
NEW
322
                }
×
NEW
323
            }
×
NEW
324
        }
×
NEW
325
    }
×
326

327
    private static List<List<string>> ParseCsvLines(string csv)
NEW
328
    {
×
NEW
329
        var lines = new List<List<string>>();
×
NEW
330
        var currentLine = new List<string>();
×
NEW
331
        var currentValue = new StringBuilder();
×
NEW
332
        bool inQuotes = false;
×
NEW
333
        int i = 0;
×
334

NEW
335
        while (i < csv.Length)
×
NEW
336
        {
×
NEW
337
            char c = csv[i];
×
338

NEW
339
            if (inQuotes)
×
NEW
340
            {
×
NEW
341
                if (c == '"')
×
NEW
342
                {
×
343
                    // Check for escaped quote
NEW
344
                    if (i + 1 < csv.Length && csv[i + 1] == '"')
×
NEW
345
                    {
×
NEW
346
                        currentValue.Append('"');
×
NEW
347
                        i += 2;
×
NEW
348
                        continue;
×
349
                    }
350
                    else
NEW
351
                    {
×
NEW
352
                        inQuotes = false;
×
NEW
353
                        i++;
×
NEW
354
                        continue;
×
355
                    }
356
                }
357
                else
NEW
358
                {
×
NEW
359
                    currentValue.Append(c);
×
NEW
360
                    i++;
×
NEW
361
                    continue;
×
362
                }
363
            }
364

365
            // Not in quotes
NEW
366
            if (c == '"')
×
NEW
367
            {
×
NEW
368
                inQuotes = true;
×
NEW
369
                i++;
×
NEW
370
            }
×
NEW
371
            else if (c == ',')
×
NEW
372
            {
×
NEW
373
                currentLine.Add(currentValue.ToString());
×
NEW
374
                currentValue.Clear();
×
NEW
375
                i++;
×
NEW
376
            }
×
NEW
377
            else if (c == '\r')
×
NEW
378
            {
×
379
                // Handle \r\n or just \r
NEW
380
                currentLine.Add(currentValue.ToString());
×
NEW
381
                currentValue.Clear();
×
NEW
382
                lines.Add(currentLine);
×
NEW
383
                currentLine = [];
×
NEW
384
                i++;
×
NEW
385
                if (i < csv.Length && csv[i] == '\n')
×
NEW
386
                {
×
NEW
387
                    i++;
×
NEW
388
                }
×
NEW
389
            }
×
NEW
390
            else if (c == '\n')
×
NEW
391
            {
×
NEW
392
                currentLine.Add(currentValue.ToString());
×
NEW
393
                currentValue.Clear();
×
NEW
394
                lines.Add(currentLine);
×
NEW
395
                currentLine = [];
×
NEW
396
                i++;
×
NEW
397
            }
×
398
            else
NEW
399
            {
×
NEW
400
                currentValue.Append(c);
×
NEW
401
                i++;
×
NEW
402
            }
×
NEW
403
        }
×
404

405
        // Add final value and line
NEW
406
        if (currentValue.Length > 0 || currentLine.Count > 0)
×
NEW
407
        {
×
NEW
408
            currentLine.Add(currentValue.ToString());
×
NEW
409
            lines.Add(currentLine);
×
NEW
410
        }
×
411

NEW
412
        return lines;
×
NEW
413
    }
×
414

415
    private static string EscapeCsvValue(string value)
NEW
416
    {
×
NEW
417
        if (string.IsNullOrEmpty(value))
×
NEW
418
        {
×
NEW
419
            return value;
×
420
        }
421

NEW
422
        bool needsQuotes = value.Contains(',') ||
×
NEW
423
                           value.Contains('"') ||
×
NEW
424
                           value.Contains('\n') ||
×
NEW
425
                           value.Contains('\r');
×
426

NEW
427
        if (!needsQuotes)
×
NEW
428
        {
×
NEW
429
            return value;
×
430
        }
431

432
        // Escape quotes by doubling them and wrap in quotes
NEW
433
        return '"' + value.Replace("\"", "\"\"") + '"';
×
NEW
434
    }
×
435
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc