Skip to content

Commit 1ddefc9

Browse files
Add word wrap support for more unicode whitespace characters. (#488)
I replaced space detection logic for lyrics word wrap calculation, as well as a few other places it made sense such as blank line detection. The new logic uses the `isspace`/`iswspace` functions to determine if a character is whitespace based on the current c++ locale. This approach allows for flexible whitespace classification for any Unicode character. I wasn't sure whether to put the `find_first_space` and `find_last_space` functions in `win32_util.cpp` or `ui_util.cpp`, but I went with `win32_util.cpp` because the functions could theoretically be used outside of a UI context. --------- Co-authored-by: Jacques Heunis <github@jacquesheunis.com>
1 parent c984d81 commit 1ddefc9

6 files changed

Lines changed: 189 additions & 9 deletions

File tree

src/lyric_auto_edit.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ static std::optional<LyricData> RemoveRepeatedSpaces(const LyricData& lyrics)
4949
size_t search_start = 0;
5050
while(search_start < line.text.length())
5151
{
52-
size_t next_space = line.text.find_first_of(_T(' '), search_start);
52+
size_t next_space = find_first_whitespace(line.text, search_start);
5353

5454
// NOTE: If the line was empty we would not enter this loop.
5555
// We subtract 1 from the length to avoid overflowing when next_space == npos == (size_t)-1
@@ -60,7 +60,7 @@ static std::optional<LyricData> RemoveRepeatedSpaces(const LyricData& lyrics)
6060
}
6161

6262
size_t erase_start = next_space + 1;
63-
size_t erase_end = line.text.find_first_not_of(_T(' '), erase_start);
63+
size_t erase_end = find_first_nonwhitespace(line.text, erase_start);
6464

6565
if((erase_end != std::tstring::npos) && (erase_end > erase_start))
6666
{
@@ -90,7 +90,7 @@ static std::optional<LyricData> RemoveRepeatedBlankLines(const LyricData& lyrics
9090
LyricData new_lyrics = lyrics;
9191
for(auto iter = new_lyrics.lines.begin(); iter != new_lyrics.lines.end(); /*Omitted*/)
9292
{
93-
size_t first_non_space = iter->text.find_first_not_of(' ');
93+
size_t first_non_space = find_first_nonwhitespace(iter->text);
9494
bool is_blank = (first_non_space == std::tstring::npos);
9595
if(is_blank && previous_blank)
9696
{

src/ui_lyric_editor.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ void LyricEditor::SelectLineWithTimestampGreaterOrEqual(double threshold_timesta
326326
line_buffer_len); // EM_GETLINE reads the first word as the number of characters in the buffer
327327
LRESULT chars_copied = SendDlgItemMessage(IDC_LYRIC_TEXT, EM_GETLINE, i, (LPARAM)line_buffer);
328328
std::string linestr = from_tstring(std::tstring_view { line_buffer, (size_t)chars_copied });
329-
if(linestr.empty() || ((linestr.length() == 1) && (linestr[0] == ' '))) continue;
329+
if(linestr.empty() || ((linestr.length() == 1) && is_char_whitespace(linestr[0]))) continue;
330330
if(parsers::lrc::is_tag_line(linestr)) continue;
331331

332332
double line_timestamp = parsers::lrc::get_line_first_timestamp(linestr);

src/ui_lyrics_externalwindow.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,8 +427,8 @@ static int _WrapSimpleLyricsLineToRect(D2DTextRenderContext& render,
427427
// Remove trailing whitespace
428428
// We do this once now (before allocating anything dependent on string length)
429429
// and then since we don't ever move the "end" of the string, we assume that line
430-
// doesn't end in a space for the rest of the function.
431-
size_t last_not_space = line.find_last_not_of(_T(' '));
430+
// doesn't end in whitespace for the rest of the function.
431+
size_t last_not_space = find_last_nonwhitespace(line);
432432
if(last_not_space == std::tstring_view::npos)
433433
{
434434
return line_height; // Our line is exclusively whitespace

src/ui_lyrics_panel.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,10 +447,10 @@ static int _WrapSimpleLyricsLineToRect(HDC dc, CRect clip_rect, std::tstring_vie
447447
int total_height = 0;
448448
while(text_outstanding.length() > 0)
449449
{
450-
size_t leading_spaces = text_outstanding.find_first_not_of(_T(' '));
450+
size_t leading_spaces = find_first_nonwhitespace(text_outstanding);
451451
text_outstanding.remove_prefix(std::min(leading_spaces, text_outstanding.size()));
452452

453-
size_t last_not_space = text_outstanding.find_last_not_of(_T(' '));
453+
size_t last_not_space = find_last_nonwhitespace(text_outstanding);
454454
if(last_not_space != std::tstring_view::npos)
455455
{
456456
size_t trailing_spaces = text_outstanding.length() - 1 - last_not_space;
@@ -476,7 +476,7 @@ static int _WrapSimpleLyricsLineToRect(HDC dc, CRect clip_rect, std::tstring_vie
476476
else
477477
{
478478
assert(chars_to_draw > 0);
479-
const int previous_space_index = int(text_outstanding.rfind(' ', chars_to_draw - 1));
479+
const int previous_space_index = int(find_last_whitespace(text_outstanding, chars_to_draw - 1));
480480
if(previous_space_index == std::tstring::npos)
481481
{
482482
// There is a single word that doesn't fit on the line

src/win32_util.cpp

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,63 @@ std::tstring normalise_utf8(std::tstring_view input)
158158
return result;
159159
}
160160

161+
bool is_char_whitespace(TCHAR c)
162+
{
163+
// U+00A0 and U+202F are non-breaking spaces.
164+
// U+180E was classified as a space when microsoft first defined isspace, but was later removed from the standard.
165+
return (_istspace(c) > 0) && (c != L'\u00A0') && (c != L'\u202F') && (c != L'\u180E');
166+
}
167+
168+
size_t find_first_whitespace(const std::tstring_view str, size_t pos)
169+
{
170+
// match behavior of std::string_view::find_first_of
171+
if(pos >= str.length() || str.empty()) return std::tstring_view::npos;
172+
173+
const auto it = std::find_if(std::next(str.begin(), pos), str.end(), is_char_whitespace);
174+
175+
if(it == str.end()) return std::tstring_view::npos;
176+
177+
return it - str.begin();
178+
}
179+
180+
size_t find_first_nonwhitespace(const std::tstring_view str, size_t pos)
181+
{
182+
// match behavior of std::string_view::find_first_not_of
183+
if(pos >= str.length() || str.empty()) return std::tstring_view::npos;
184+
185+
const auto it = std::find_if_not(std::next(str.begin(), pos), str.end(), is_char_whitespace);
186+
187+
if(it == str.end()) return std::tstring_view::npos;
188+
189+
return it - str.begin();
190+
}
191+
192+
size_t find_last_whitespace(const std::tstring_view str, size_t pos)
193+
{
194+
if(str.empty()) return std::tstring_view::npos;
195+
196+
size_t offset = 0;
197+
if(pos != std::tstring_view::npos && pos < str.length()) offset = str.length() - pos - 1;
198+
199+
const auto it = std::find_if(std::next(str.rbegin(), offset), str.rend(), is_char_whitespace);
200+
if(it == str.rend()) return std::tstring_view::npos;
201+
202+
return str.rend() - it - 1;
203+
}
204+
205+
size_t find_last_nonwhitespace(const std::tstring_view str, size_t pos)
206+
{
207+
if(str.empty()) return std::tstring_view::npos;
208+
209+
size_t offset = 0;
210+
if(pos != std::tstring_view::npos && pos < str.length()) offset = str.length() - pos - 1;
211+
212+
const auto it = std::find_if_not(std::next(str.rbegin(), offset), str.rend(), is_char_whitespace);
213+
if(it == str.rend()) return std::tstring_view::npos;
214+
215+
return str.rend() - it - 1;
216+
}
217+
161218
bool hr_success(HRESULT result, const char* filename, int line_number)
162219
{
163220
const bool success = (result == S_OK);
@@ -210,4 +267,121 @@ MVTF_TEST(win32_string_narrow_to_wide_handles_ascii)
210267
const std::wstring output = std::wstring(output_buffer.data(), output_chars);
211268
ASSERT(output == L"test string!\nwith a newline :O");
212269
}
270+
271+
MVTF_TEST(win32_is_char_whitespace_true_for_breaking_whitespace)
272+
{
273+
ASSERT(is_char_whitespace(L'\t'));
274+
ASSERT(is_char_whitespace(L'\n'));
275+
ASSERT(is_char_whitespace(L'\v'));
276+
ASSERT(is_char_whitespace(L'\f'));
277+
ASSERT(is_char_whitespace(L'\r'));
278+
ASSERT(is_char_whitespace(L' '));
279+
280+
ASSERT(is_char_whitespace(L'\u0085')); // Next line
281+
ASSERT(is_char_whitespace(L'\u1680')); // Ogham space mark
282+
283+
ASSERT(is_char_whitespace(L'\u2000')); // En quad
284+
ASSERT(is_char_whitespace(L'\u2001')); // Em quad
285+
ASSERT(is_char_whitespace(L'\u2002')); // En space
286+
ASSERT(is_char_whitespace(L'\u2003')); // Em space
287+
ASSERT(is_char_whitespace(L'\u2004')); // Three-per-em space
288+
ASSERT(is_char_whitespace(L'\u2005')); // Four-per-em space
289+
ASSERT(is_char_whitespace(L'\u2006')); // Six-per-em space
290+
ASSERT(is_char_whitespace(L'\u2007')); // Figure space
291+
ASSERT(is_char_whitespace(L'\u2008')); // Punctuation space
292+
ASSERT(is_char_whitespace(L'\u2009')); // Thin space
293+
ASSERT(is_char_whitespace(L'\u200A')); // Hair space
294+
295+
ASSERT(is_char_whitespace(L'\u2028')); // Line separator
296+
ASSERT(is_char_whitespace(L'\u2029')); // Paragraph separator
297+
ASSERT(is_char_whitespace(L'\u205F')); // Medium mathematical space
298+
ASSERT(is_char_whitespace(L'\u3000')); // Ideographic space
299+
300+
ASSERT(!is_char_whitespace(L'\u00A0')); // Non-breaking space
301+
ASSERT(!is_char_whitespace(L'\u202F')); // Narrow non-breaking space
302+
ASSERT(!is_char_whitespace(L'\u180E')); // Mongolian vowel separator
303+
ASSERT(!is_char_whitespace(L'A'));
304+
ASSERT(!is_char_whitespace(L'1'));
305+
ASSERT(!is_char_whitespace(L'-'));
306+
}
307+
308+
MVTF_TEST(win32_find_first_whitespace_gives_correct_indices)
309+
{
310+
std::tstring_view input = _T("Test string.\u3000Second sentence.");
311+
ASSERT(find_first_whitespace(input) == 4);
312+
ASSERT(find_first_whitespace(input, 4) == 4);
313+
ASSERT(find_first_whitespace(input, 5) == 12);
314+
ASSERT(find_first_whitespace(input, 23) == std::tstring_view::npos);
315+
ASSERT(find_first_whitespace(input, 100) == std::tstring_view::npos);
316+
}
317+
318+
MVTF_TEST(win32_find_first_whitespace_empty_string)
319+
{
320+
ASSERT(find_first_whitespace(_T("")) == std::tstring_view::npos);
321+
}
322+
323+
MVTF_TEST(win32_find_first_whitespace_no_whitespace)
324+
{
325+
ASSERT(find_last_whitespace(_T("abcdef")) == std::tstring_view::npos);
326+
}
327+
328+
MVTF_TEST(win32_find_first_nonwhitespace_gives_correct_indices)
329+
{
330+
std::tstring_view input = _T(" \u3000Test string. ");
331+
ASSERT(find_first_nonwhitespace(input) == 4);
332+
ASSERT(find_first_nonwhitespace(input, 4) == 4);
333+
ASSERT(find_first_nonwhitespace(input, 10) == 12);
334+
ASSERT(find_first_nonwhitespace(input, 20) == std::tstring_view::npos);
335+
ASSERT(find_first_nonwhitespace(input, 100) == std::tstring_view::npos);
336+
}
337+
338+
MVTF_TEST(win32_find_first_nonwhitespace_empty_string)
339+
{
340+
ASSERT(find_first_nonwhitespace(_T("")) == std::tstring_view::npos);
341+
}
342+
343+
MVTF_TEST(win32_find_first_nonwhitespace_no_nonwhitespace)
344+
{
345+
ASSERT(find_last_nonwhitespace(_T(" ")) == std::tstring_view::npos);
346+
}
347+
348+
MVTF_TEST(win32_find_last_whitespace_gives_correct_indices)
349+
{
350+
std::tstring_view input = _T("Test string.\u3000Second sentence.");
351+
ASSERT(find_last_whitespace(input) == 19);
352+
ASSERT(find_last_whitespace(input, 19) == 19);
353+
ASSERT(find_last_whitespace(input, 15) == 12);
354+
ASSERT(find_last_whitespace(input, 2) == std::tstring_view::npos);
355+
ASSERT(find_last_whitespace(input, 100) == 19);
356+
}
357+
358+
MVTF_TEST(win32_find_last_whitespace_empty_string)
359+
{
360+
ASSERT(find_last_whitespace(_T("")) == std::tstring_view::npos);
361+
}
362+
363+
MVTF_TEST(win32_find_last_whitespace_no_whitespace)
364+
{
365+
ASSERT(find_last_whitespace(_T("abcdef")) == std::tstring_view::npos);
366+
}
367+
368+
MVTF_TEST(win32_find_last_nonwhitespace_gives_correct_indices)
369+
{
370+
std::tstring_view input = _T(" \u3000Test string. ");
371+
ASSERT(find_last_nonwhitespace(input) == 18);
372+
ASSERT(find_last_nonwhitespace(input, 18) == 18);
373+
ASSERT(find_last_nonwhitespace(input, 10) == 7);
374+
ASSERT(find_last_nonwhitespace(input, 3) == std::tstring_view::npos);
375+
ASSERT(find_last_nonwhitespace(input, 100) == 18);
376+
}
377+
378+
MVTF_TEST(win32_find_last_nonwhitespace_empty_string)
379+
{
380+
ASSERT(find_last_nonwhitespace(_T("")) == std::tstring_view::npos);
381+
}
382+
383+
MVTF_TEST(win32_find_last_nonwhitespace_no_nonwhitespace)
384+
{
385+
ASSERT(find_last_nonwhitespace(_T(" ")) == std::tstring_view::npos);
386+
}
213387
#endif

src/win32_util.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,11 @@ std::string from_tstring(const std::tstring& string);
2929

3030
std::tstring normalise_utf8(std::tstring_view input);
3131

32+
bool is_char_whitespace(TCHAR c);
33+
size_t find_first_whitespace(const std::tstring_view str, size_t pos = 0);
34+
size_t find_first_nonwhitespace(const std::tstring_view str, size_t pos = 0);
35+
size_t find_last_whitespace(const std::tstring_view str, size_t pos = std::tstring_view::npos);
36+
size_t find_last_nonwhitespace(const std::tstring_view str, size_t pos = std::tstring_view::npos);
37+
3238
#define HR_SUCCESS(hr) hr_success(hr, __FILE__, __LINE__)
3339
bool hr_success(HRESULT result, const char* filename, int line_number);

0 commit comments

Comments
 (0)