c# wpf проверка на последнее слово текущей строки

Создаю сырую страничку с проверкой правильности печатания. Суть в том что вы вводите текст и если он совпадает с оригинальным, то ваш символ белый, иначе красный. Проблема состоит в том что я для вывода текста использую textblock с textwrapping и там слова переносятся на новую строку если не помещаются, но при вводе мои слова на половину остаются на прежней и на половину на новой(из-за wrappanel), а мне нужно чтобы перенос был корректным и происходил он за счет того чтобы я как-то замечал что человек ввел последнее слово на текущей строке и после этого удалял бы строку из текста, но как это сделать я не знаю, код и скрины проблемы прилагаю ниже:

скриншот: введите сюда описание изображения

как видно тут слово bloomed на половину сверху и чуть чуть снизу. Чтобы этого не допускать я хочу как-то ловить то что человек ввел последнее слово текущей строки(как в примере freshly) и после этого бы обновлял текст удаляя текущую строку, а в коллекции очищая ее.

код view:

<Grid Background="#282a36">
    <TextBlock Text="{Binding OriginalText}"
               FontFamily="Consolas"
               Foreground="Gray" 
               FontSize="16" 
               IsHitTestVisible="False"
               TextWrapping="Wrap"/>
    <TextBox Text="{Binding UserInput, UpdateSourceTrigger=PropertyChanged}" 
             FontSize="16" 
             FontFamily="Consolas"
             Background="Transparent" 
             Foreground="Transparent" 
             BorderBrush="Transparent"
             TextWrapping="Wrap"/>
    <ItemsControl ItemsSource="{Binding ColoredUserInput}" VerticalAlignment="Top">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel Orientation="Horizontal"/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Character}" 
                           Foreground="{Binding Color}" 
                           FontSize="16"
                           FontFamily="Consolas"/>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</Grid>

Код ViewModel:

public class MainViewModel : INotifyPropertyChanged
{
    private string _originalText = "The curious cat explored every corner of the garden, chasing butterflies and sniffing at freshly bloomed flowers. Meanwhile, the old oak tree stood tall, its branches swaying gently in the summer breeze. Birds chirped happily overhead, creating a symphony of natural sounds. The sun painted the sky in shades of orange and pink as evening approached.";
    private string _userInput;
    private ObservableCollection<ColoredCharacter> _coloredUserInput;

    public MainViewModel()
    {
        ColoredUserInput = new ObservableCollection<ColoredCharacter>();
    }

    public string OriginalText
    {
        get => _originalText;
        set
        {
            _originalText = value;
            OnPropertyChanged();
        }
    }

    public string UserInput
    {
        get => _userInput;
        set
        {
            _userInput = value;
            OnPropertyChanged();
            UpdateColoredUserInput();
        }
    }

    public ObservableCollection<ColoredCharacter> ColoredUserInput
    {
        get => _coloredUserInput;
        set
        {
            _coloredUserInput = value;
            OnPropertyChanged();
        }
    }

    private void UpdateColoredUserInput()
    {
        ColoredUserInput.Clear();
        for (int i = 0; i < OriginalText.Length; i++)
        {
            if (i < UserInput.Length)
            {
                var character = OriginalText[i];
                var color = UserInput[i] == character ? Brushes.White : Brushes.Red;
                ColoredUserInput.Add(new ColoredCharacter { Character = character.ToString(), Color = color });
            }
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

public class ColoredCharacter
{
    public string Character { get; set; }
    public Brush Color { get; set; }
}

так как я говорил выше страничка сырая(используется только для создания того, что я в будущем перенесу в проект), то тут не чистый код про который я знаю в большинстве случаев, но если тут будет что-то прям плохое, то буду рад узнать!

Обновлено: из идей пришло то что я сам буду создавать переносы в тексте через метод, а в UpdateColoredUserInput просто проверять если следующий символ есть и он равен \n, то удалять текущую строку, но тут новая проблема в создании этого метода ведь эти \n нужно вставлять в проверке поместится ли новое слово в текущую строку по символам(а тут и надо придумать как высчитать исходя из размера шрифта и ширины textblock да и звучит это в целом как не прям хороший способ, но он хотя бы представляется как-то в отличие от метода который магическим образом(либо через formattedtext) узнает поместится ли слово).


Ответы (2 шт):

Автор решения: ikikaidesu

Решил данную проблему, помимо нее как писал EvgeniyZ у меня есть еще парочка которые я также решу, но чутка позже. Как я и говорил я создал метод который бы форматировал строку создавая искусственный перенос при достижении длины текущей строки большей чем макс. кол-во символов в строке. По сути мой код также не сильно хорош ведь там FormattedText где я присваиваю ui значения, то есть мне придется их где-то хранить и также привязывать к textblock чтобы не было различий. как раз одна из проблем как я уже говорил это то, что я храню размер шрифта и размер textblock в viewmodel, но по другому как сделать я правда не знаю

// метод для получения средней ширины символа в строке
private double GetAverageCharacterWidth()
{
    // создаем "виртуальный" текстбокс где подставляем данные как у нашего оригинального, это нужно чтобы получить среднюю ширину символа
    // так как у нас шрифт Consolas, то и средняя ширина у всех одинаковая
    var formattedText = new FormattedText(
        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
        CultureInfo.CurrentCulture,
        FlowDirection.LeftToRight,
        new Typeface("Consolas"),
        _charSize,
        Brushes.Black,
        new NumberSubstitution(),
        1);

    return formattedText.Width / formattedText.Text.Length;
}

// этот метод определяет макс. кол-во допустимых символов в строке
private int GetMaxCharsPerLine()
{
    // получаем ширину символа
    double averageCharWidth = GetAverageCharacterWidth();
    // - 10 так как при оригинальном размере текст выходит за пределы буквально на символ
    return (int)((_textbLockWidth-10) / averageCharWidth);
}
// метод добавляющий переносы в строках
private string AddLineBreaks(string text, int maxCharsPerLine)
{
    // делаем из нашего текста массив для корректного обхода текста по словам
    var words = text.Split(' ');
    // создаем стрингбилдер
    var result = new StringBuilder();
    // создаем отчет для кол-ва символов в строке текущей
    var currentLineLength = 0;

    // обходим слова
    foreach (var word in words)
    {
        // если длина строки + длина слова + 1 больше макс. допущенного значения
        if (currentLineLength + word.Length + 1 > maxCharsPerLine)
        {
            // создаем перенос и обнуляем счетчик
            result.Append('\n');
            currentLineLength = 0;
        }
        // тут мы делаем проверку что слово в строке не первое и если это так добавляем пробел и увеличиваем счетчик
        else if (currentLineLength > 0)
        {
            result.Append(' ');
            currentLineLength++;
        }
        // добавляем слово 
        result.Append(word);
        // увеличиваем счетчик
        currentLineLength += word.Length;
    }
    // возвращаем отформатированную строку
    return result.ToString();
}

а вот метод который я вызываю в UpdateColoredUserInput после добавления в коллекцию передавая текущий i, этот метод проверяет является ли символ \n и если так, то убирает текущую строку и очищает пользовательский ввод.

// метод проверки на новую строку
    private void CheckForNewLine(int LastCharIndex)
    {
        // если символ есть и он равен \n
        if (OriginalText.Length > LastCharIndex + 1 && OriginalText[LastCharIndex + 1] == '\n')
        {
            // пропускаем текущую строку чтобы удалить ее и создать перенос на новую
            OriginalText = new string(OriginalText.Skip(LastCharIndex + 2).ToArray());
            // очищаем пользовательский ввод
            UserInput = string.Empty;
        }
    }

Скорее всего код не идеален, поэтому надеюсь на ваши идеи по его улучшению.

→ Ссылка
Автор решения: EvgeniyZ

Покажу пожалуй вам пример того, о чем я говорю. Это не ответ на ваш вопрос, а скорей, некая альтернатива.

  • Нам нужна текущая нажатая кнопка. Тут можно поступить как сделали вы, через текстовое поле, но это костыль, который плодит другие костыли, да и вам от него нужна лишь текущая кнопка. Как быть? Есть 2 варианта:

    1. Через "интерактивность"

      • Качаете NuGet пакет Microsoft.Xaml.Behaviors.Wpf

      • В начале XAML подключаете его: xmlns:i="http://schemas.microsoft.com/xaml/behaviors"

      • Далее делаете у нужного объекта (например, окно) что-то по типу этого:

        <i:Interaction.Triggers>
            <i:EventTrigger EventName="KeyDown">
                <i:InvokeCommandAction Command="{Binding KeyDownCommand}" PassEventArgsToCommand="True" />
            </i:EventTrigger>
        </i:Interaction.Triggers>
        

        Как видите, мы привязываемся к конкретному событию, при срабатывании которого вызывается команда. Ну а так, как нам нужны данные этой команды, то передаем в нее EventArgs (аргументы). Сама команда будет стандартная, аргументом получаем KeyEventArgs. Минус этого подхода в том, что он не совсем MVVM, ибо KeyEventArgs и даже Key - это System.Windows.Input, что явно намекает нам на то, что это View слой. Если для вас это не критично, то вариант вполне хороший. P.S. Событие KeyDown не дает локализированную букву (что вам надо), поэтому лучше использовать PreviewTextInput, который дает сразу локализированный текст текущей раскладки. Привязка будет аналогична за исключением типа аргумента.

    2. Через интерфейс

      MVVM - это про разделение всего на мало связанные слои, а это в свою очередь означает то, что мы можем общаться с View, как и View может общаться с VM, но делать это надо аккуратно и через абстракцию, интерфейсы.

      • Создаем интерфейс, который будет описывать логику получения нажатой кнопки, например, такой:

        public interface IKeyHandle
        {
            void KeyDown(string key);
        }
        
      • Далее в конструкторе окна, или даже лучше по событию окна Loaded, мы проверяем "А является-ли DataContext этим интерфейсом?" Если да, то подписываемся на нужное событие и передаем нужные данные в VM.

        if (DataContext is IKeyHandle keyHandle)
        {
            PreviewTextInput += (_, args) => keyHandle.KeyDown(args.Text);
        }
        
      • Далее, наследуем VM класс от этого интерфейса и реализуем его, вот собственно и еще один вариант передачи данных.

Имея на руках нажатый символ, мы можем двигаться дальше, а именно, писать интерфейс и основную логику. Начнем с логики...

  • Нам нужно "Состояние" каждой буквы. Пусть это будет "Без состояния", "Выделен", "Верный", "Неверный".

    public enum CharacterState
    {
        None,
        Selected,
        Correct,
        Incorrect
    }
    
  • Далее, создадим VM каждой буквы, которая будет в себе содержать сам символ и состояние. Состояние меняется во время работы, а значит он должен вызывать INPC. Я буду использовать CommunityToolkit, вы можете любой другой способ использовать.

    public partial class CharacterViewModel(char character)  : ObservableObject
    {
        public char Character { get; } = character;
    
        [ObservableProperty]
        private CharacterState _state;
    }
    
  • Далее, создаем коллекцию этих VM:

    public ObservableCollection<CharacterViewModel> Characters { get; }
    
  • Заполняем ее любым удобным способом, я буду делать в конструкторе следующее:

    Characters = new("Сьешь ещё этих мягких французких булок, да выпей чаю.".Select(x => new CharacterViewModel(x)));
    
  • Теперь давайте сделаем метод, который будет выбирать следующий символ, что-то вроде этого:

    private int _currentCharIndex;
    private CharacterViewModel? _currentCharacter;
    
    private void MoveSelection()
    {
        if (_currentCharacter is { State: CharacterState.Selected }) return;
    
        // TODO: Проверка индекса
        _currentCharacter = Characters[_currentCharIndex++];
        _currentCharacter.State = CharacterState.Selected;
    }
    
  • Нам надо вызвать этот метод один раз при "старте теста" (если у вас так), ну или как сделаю я, просто в конструкторе.

  • Теперь тот метод, который вызывается при клике, в нем прописываем логику проверки, ну и заодно перемещаемся на следующий символ:

    public void KeyDown(string key)
    {
        if (_currentCharacter is null || !char.TryParse(key, out var pressedChar)) return;
    
        _currentCharacter.State = pressedChar == _currentCharacter?.Character ? CharacterState.Correct : CharacterState.Incorrect;
    
        MoveSelection();
    }
    

Все, с логикой завершили, теперь UI, он будет максимально простой, а именно ItemsControl с заданным как у вас ItemsPanel, ну а в ItemTemplate будет Border для выделения и TextBlock самого символа, также там будут триггеры, которые зададут нужные цвета:

<ItemsControl ItemsSource="{Binding Characters}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapPanel />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Border x:Name="border" BorderThickness="0,0,0,1">
                <TextBlock
                    x:Name="textBox"
                    FontSize="16"
                    Text="{Binding Character}" />
            </Border>
            <DataTemplate.Triggers>
                <DataTrigger Binding="{Binding State}" Value="Selected">
                    <Setter TargetName="border" Property="BorderBrush" Value="Black" />
                </DataTrigger>
                <DataTrigger Binding="{Binding State}" Value="Incorrect">
                    <Setter TargetName="textBox" Property="Foreground" Value="Red" />
                </DataTrigger>
                <DataTrigger Binding="{Binding State}" Value="Correct">
                    <Setter TargetName="textBox" Property="Foreground" Value="Green" />
                </DataTrigger>
            </DataTemplate.Triggers>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

На всякий случай, весь код:


public interface IKeyHandle
{
    void KeyDown(string key);
}

public enum CharacterState
{
    None,
    Selected,
    Correct,
    Incorrect
}

public partial class CharacterViewModel(char character) : ObservableObject
{
    public char Character { get; } = character;

    [ObservableProperty]
    private CharacterState _state;
}

public partial class MainViewModel : ObservableObject, IKeyHandle
{
    public ObservableCollection<CharacterViewModel> Characters { get; }

    public MainViewModel()
    {
        Characters = new("Сьешь ещё этих мягких французких булок, да выпей чаю.".Select(x => new CharacterViewModel(x)));
        MoveSelection();
    }

    private int _currentCharIndex;
    private CharacterViewModel? _currentCharacter;

    private void MoveSelection()
    {
        if (_currentCharacter is { State: CharacterState.Selected }) return;

        // TODO: Проверка индекса
        _currentCharacter = Characters[_currentCharIndex++];
        _currentCharacter.State = CharacterState.Selected;
    }

    public void KeyDown(string key)
    {
        if (_currentCharacter is null || !char.TryParse(key, out var pressedChar)) return;

        _currentCharacter.State = pressedChar == _currentCharacter?.Character ? CharacterState.Correct : CharacterState.Incorrect;

        MoveSelection();
    }

}

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        DataContext = new MainViewModel();

        if (DataContext is IKeyHandle keyHandle)
        {
            PreviewTextInput += (_, args) => keyHandle.KeyDown(args.Text);
        }
    }
}

Ну и результат:

Result

Как видите, относительно просто, без лишних костылей, простой набор букв, которые вы без труда можете масштабировать, менять шрифт, и прочее, хоть в столбик размещайте, все будет работать и переноситься как надо.

→ Ссылка