.net 在WPF中创建吉他和弦编辑器(来自RichTextBox?)

.net 在WPF中创建吉他和弦编辑器(来自RichTextBox?),.net,wpf,user-interface,richtextbox,music-notation,.net,Wpf,User Interface,Richtextbox,Music Notation,我在WPF中工作的应用程序的主要目的是允许编辑和打印带有吉他和弦的歌曲歌词 即使你不演奏任何乐器,你也可能见过和弦。给你一个想法,它看起来像这样: E E6 I know I stand in line until you E E6 F#m B F#m B think you have the time to spend an evening with me 但是我不想使用这种难

我在WPF中工作的应用程序的主要目的是允许编辑和打印带有吉他和弦的歌曲歌词

即使你不演奏任何乐器,你也可能见过和弦。给你一个想法,它看起来像这样:

E                 E6
I know I stand in line until you
E                  E6               F#m            B F#m B
think you have the time to spend an evening with me
但是我不想使用这种难看的单间距字体,而是希望使用
Times New Roman
字体,同时对歌词和和弦(粗体和弦)进行紧排。我希望用户能够编辑这个

RichTextBox
似乎不支持这种情况。以下是一些我不知道如何解决的问题:

  • 和弦的位置固定在歌词文本中的某些字符上(或者更一般地说是歌词行的
    TextPointer
    )。当用户编辑歌词,我希望和弦停留在正确的字符。例如:

  • 换行:换行时,2行(第1行带和弦,第2行带歌词)在逻辑上是一行。当一个单词换行到下一行时,它上面的所有和弦也应该换行。同样,当和弦把它所在的单词包起来时,它也会包起来。例如:

  • 和弦应该保持在正确的角色上,即使和弦彼此太近。在这种情况下,会在歌词行中自动插入一些额外的空间。例如:

  • 假设我有歌词行
    Ta VA
    和和弦在
    A
    上。我希望歌词看起来像不像。第二张图片未在
    V
    A
    之间紧排。橙色线仅用于可视化效果(但它们标记了弦放置的x偏移)。用于生成第一个样本的代码为Ta VA,用于生成第二个样本的代码为Ta VA
关于如何获取RichTextBox来执行此操作,您有什么想法吗?还是有更好的方法在WPF中实现?我是否将子分类
Inline
运行
帮助?欢迎任何想法、黑客、
TextPointer
magic、代码或相关主题的链接


编辑: 我正在探索解决这个问题的两个主要方向,但这两个方向都会导致另一个问题,所以我提出了一个新问题:

  • 尝试将
    RichTextBox
    转换为和弦编辑器-查看
  • 根据中的建议,从单独的组件(如
    面板
    s
    文本框
    es等)构建新编辑器。这将需要大量的编码,并导致以下(未解决的)问题:

    • (行开始处的空白清除等)
    • 在组件边界处
    • (已知的不是优雅的破解/解决方法)

  • 编辑#2 已经向我展示了使用
    RichTextBox
    可以做的事情比我自己在尝试调整它以满足我的需要时所期望的要多得多。直到现在,我才有时间详细探究答案。马库斯可能是
    RichTextBox
    魔术师我需要帮助我,但他的解决方案也存在一些未解决的问题:

  • 这个应用程序将是所有关于“美丽的”印刷歌词。主要目标是从排版的角度来看,文本看起来很完美。当和弦彼此太近甚至重叠时,Markus建议我在其位置之前迭代地添加加法空格,直到它们的距离足够。实际上,用户需要设置两个和弦之间的最小距离。应遵守该最小距离,且在必要时不得超过该距离。空间不够细化-一旦我添加了最后一个需要的空间,我可能会使间隙比必要的更大-这会使文档看起来“糟糕”,我认为它不会被接受我需要插入自定义宽度的空间
  • 有些行可能没有和弦(只有文本),甚至有些行没有文本(只有和弦)。当整个文档的
    LineHeight
    设置为
    25
    或其他固定值时,将导致没有和弦的行上方出现“空行”。当只有和弦而没有文本时,它们就没有空间了

  • 还有一些小问题,但我认为我可以解决它们,或者我认为它们并不重要。无论如何,我认为马库斯的回答真的很有价值——不仅是为了向我展示可能的方法,而且是为了展示使用adorner的
    RichTextBox
    的一般模式。

    我不能给你任何具体的帮助,但在架构方面,你需要从这个角度改变布局

    对此

    其他一切都是胡闹。单位/字形必须成为单词和弦对


    编辑:我一直在玩弄一个模板化的ItemsControl,它甚至在某种程度上起作用,因此可能会引起人们的兴趣

    <ItemsControl Grid.IsSharedSizeScope="True" ItemsSource="{Binding SheetData}"
                  Name="_chordEditor">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition SharedSizeGroup="A" Height="Auto"/>
                        <RowDefinition SharedSizeGroup="B" Height="Auto"/>
                    </Grid.RowDefinitions>
                    <Grid.Children>
                        <TextBox Name="chordTB" Grid.Row="0" Text="{Binding Chord}"/>
                        <TextBox Name="wordTB"  Grid.Row="1" Text="{Binding Word}"
                                 PreviewKeyDown="Glyph_Word_KeyDown" TextChanged="Glyph_Word_TextChanged"/>
                    </Grid.Children>
                </Grid>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
    
    private void AddNewGlyph(字符串文本,整数索引)
    {
    var glyph=new ChordWordPair(text,String.Empty);
    SheetData.Insert(索引、图示符);
    FocusGlyphTextBox(glyph,false);
    }
    专用void FocusGlyphTextBox(ChordWordPair glyph,bool movecarettoned)
    {
    var cp=_chordEditor.ItemContainerGenerator.ContainerFromItem(glyph)作为ContentPresenter;
    动作焦点动作=()=>
    {
    var grid=visualtreeheloper.GetChild(cp,0)作为网格;
    var wordTB=grid.Children[1]作为文本框;
    键盘焦点(wordTB);
    如果(MoveCareToEnd)
    {
    wordTB.CaretIndex=int.MaxValue;
    }
    };
    如果(!cp.IsLoaded)
    {
    cp.Loaded+=(s,e)=>focusAction.Invoke();
    }
    其他的
    {
    focusAction.Invoke();
    }
    }
    私有无效图示符\u Word\u TextChanged(对象发送者,TextChangedEventArgs e)
    {
    var glyph=(发送方作为框架元素)。DataContext作为ChordWordPair;
    var tb=发送方作为文本框;
    字符串[]glyphs=tb.Text.Split(“”);
    如果(glyphs.Length>1)
    {
    glyph.Word=glyphs[0];
    for(int i=1;iE                  E6
    think you have the time to spend an
    F#m            B F#m B
    evening with me
    
                      F#m E6
      ...you have the ti  me to spend... 
    
    <ItemsControl Grid.IsSharedSizeScope="True" ItemsSource="{Binding SheetData}"
                  Name="_chordEditor">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition SharedSizeGroup="A" Height="Auto"/>
                        <RowDefinition SharedSizeGroup="B" Height="Auto"/>
                    </Grid.RowDefinitions>
                    <Grid.Children>
                        <TextBox Name="chordTB" Grid.Row="0" Text="{Binding Chord}"/>
                        <TextBox Name="wordTB"  Grid.Row="1" Text="{Binding Word}"
                                 PreviewKeyDown="Glyph_Word_KeyDown" TextChanged="Glyph_Word_TextChanged"/>
                    </Grid.Children>
                </Grid>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
    
    private readonly ObservableCollection<ChordWordPair> _sheetData = new ObservableCollection<ChordWordPair>();
    public ObservableCollection<ChordWordPair> SheetData
    {
        get { return _sheetData; }
    }
    
    public class ChordWordPair: INotifyPropertyChanged
    {
        private string _chord = String.Empty;
        public string Chord
        {
            get { return _chord; }
            set
            {
                if (_chord != value)
                {
                    _chord = value;
                    // This uses some reflection extension method,
                    // a normal event raising method would do just fine.
                    PropertyChanged.Notify(() => this.Chord);
                }
            }
        }
    
        private string _word = String.Empty;
        public string Word
        {
            get { return _word; }
            set
            {
                if (_word != value)
                {
                    _word = value;
                    PropertyChanged.Notify(() => this.Word);
                }
            }
        }
    
        public ChordWordPair() { }
        public ChordWordPair(string word, string chord)
        {
            Word = word;
            Chord = chord;
        }
    
        public event PropertyChangedEventHandler PropertyChanged;
    }
    
    private void AddNewGlyph(string text, int index)
    {
        var glyph = new ChordWordPair(text, String.Empty);
        SheetData.Insert(index, glyph);
        FocusGlyphTextBox(glyph, false);
    }
    
    private void FocusGlyphTextBox(ChordWordPair glyph, bool moveCaretToEnd)
    {
        var cp = _chordEditor.ItemContainerGenerator.ContainerFromItem(glyph) as ContentPresenter;
        Action focusAction = () =>
        {
            var grid = VisualTreeHelper.GetChild(cp, 0) as Grid;
            var wordTB = grid.Children[1] as TextBox;
            Keyboard.Focus(wordTB);
            if (moveCaretToEnd)
            {
                wordTB.CaretIndex = int.MaxValue;
            }
        };
        if (!cp.IsLoaded)
        {
            cp.Loaded += (s, e) => focusAction.Invoke();
        }
        else
        {
            focusAction.Invoke();
        }
    }
    
    private void Glyph_Word_TextChanged(object sender, TextChangedEventArgs e)
    {
        var glyph = (sender as FrameworkElement).DataContext as ChordWordPair;
        var tb = sender as TextBox;
    
        string[] glyphs = tb.Text.Split(' ');
        if (glyphs.Length > 1)
        {
            glyph.Word = glyphs[0];
            for (int i = 1; i < glyphs.Length; i++)
            {
                AddNewGlyph(glyphs[i], SheetData.IndexOf(glyph) + i);
            }
        }
    }
    
    private void Glyph_Word_KeyDown(object sender, KeyEventArgs e)
    {
        var tb = sender as TextBox;
        var glyph = (sender as FrameworkElement).DataContext as ChordWordPair;
    
        if (e.Key == Key.Left && tb.CaretIndex == 0 || e.Key == Key.Back && tb.Text == String.Empty)
        {
            int i = SheetData.IndexOf(glyph);
            if (i > 0)
            {
                var leftGlyph = SheetData[i - 1];
                FocusGlyphTextBox(leftGlyph, true);
                e.Handled = true;
                if (e.Key == Key.Back) SheetData.Remove(glyph);
            }
        }
        if (e.Key == Key.Right && tb.CaretIndex == tb.Text.Length)
        {
            int i = SheetData.IndexOf(glyph);
            if (i < SheetData.Count - 1)
            {
                var rightGlyph = SheetData[i + 1];
                FocusGlyphTextBox(rightGlyph, false);
                e.Handled = true;
            }
        }
    }
    
    <Window ...>
        <AdornerDecorator>
            <!-- setting the LineHeight enables us to position the Adorner on top of the text -->
            <RichTextBox TextBlock.LineHeight="25" Padding="0,25,0,0" Name="RTB"/>
        </AdornerDecorator>    
    </Window>
    
    public partial class MainWindow
    {
        public MainWindow()
        {
            InitializeComponent();
            const string input = "E                 E6\nI know I stand in line until you\nE                  E6               F#m            B F#m B\nthink you have the time to spend an evening with me                ";
            var lines = input.Split('\n');
    
            var paragraph = new Paragraph{Margin = new Thickness(0),Padding = new Thickness(0)}; // Paragraph sets default margins, don't want those
    
            RTB.Document = new FlowDocument(paragraph);
    
            // this is getting the AdornerLayer, we explicitly included in the xaml.
            // in it's visual tree the RTB actually has an AdornerLayer, that would rather
            // be the AdornerLayer we want to get
            // for that you will either want to subclass RichTextBox to expose the Child of
            // GetTemplateChild("ContentElement") (which supposedly is the ScrollViewer
            // that hosts the FlowDocument as of http://msdn.microsoft.com/en-us/library/ff457769(v=vs.95).aspx 
            // , I hope this holds true for WPF as well, I rather remember this being something
            // called "PART_ScrollSomething", but I'm sure you will find that out)
            //
            // another option would be to not subclass from RTB and just traverse the VisualTree
            // with the VisualTreeHelper to find the UIElement that you can use for GetAdornerLayer
            var adornerLayer = AdornerLayer.GetAdornerLayer(RTB);
    
            for (var i = 1; i < lines.Length; i += 2)
            {
                var run = new Run(lines[i]);
                paragraph.Inlines.Add(run);
                paragraph.Inlines.Add(new LineBreak());
    
                var chordpos = lines[i - 1].Split(' ');
                var pos = 0;
                foreach (string t in chordpos)
                {
                    if (!string.IsNullOrEmpty(t))
                    {
                        var position = run.ContentStart.GetPositionAtOffset(pos);
                        adornerLayer.Add(new ChordAdorner(RTB,t,position));
                    }
                    pos += t.Length + 1;
                }
            }
    
        }
    }
    
    public class ChordAdorner : Adorner
    {
        private readonly TextPointer _position;
    
        private static readonly PropertyInfo TextViewProperty = typeof(TextSelection).GetProperty("TextView", BindingFlags.Instance | BindingFlags.NonPublic);
        private static readonly EventInfo TextViewUpdateEvent = TextViewProperty.PropertyType.GetEvent("Updated");
    
        private readonly FormattedText _formattedText;
    
        public ChordAdorner(RichTextBox adornedElement, string chord, TextPointer position) : base(adornedElement)
        {
            _position = position;
            // I'm in no way associated with the font used, nor recommend it, it's just the first example I found of FormattedText
            _formattedText = new FormattedText(chord, CultureInfo.GetCultureInfo("en-us"),FlowDirection.LeftToRight,new Typeface(new FontFamily("Arial").ToString()),12,Brushes.Black);
    
            // this is where the magic starts
            // you would otherwise not know when to actually reposition the drawn Chords
            // you could otherwise only subscribe to TextChanged and schedule a Dispatcher
            // call to update this Adorner, which either fires too often or not often enough
            // that's why you're using the RichTextBox.Selection.TextView.Updated event
            // (you're then basically updating the same time that the Caret-Adorner
            // updates it's position)
            Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
            {
                object textView = TextViewProperty.GetValue(adornedElement.Selection, null);
                TextViewUpdateEvent.AddEventHandler(textView, Delegate.CreateDelegate(TextViewUpdateEvent.EventHandlerType, ((Action<object, EventArgs>)TextViewUpdated).Target, ((Action<object, EventArgs>)TextViewUpdated).Method));
                InvalidateVisual(); //call here an event that triggers the update, if 
                                    //you later decide you want to include a whole VisualTree
                                    //you will have to change this as well as this ----------.
            }));                                                                          // |
        }                                                                                 // |
                                                                                          // |
        public void TextViewUpdated(object sender, EventArgs e)                           // |
        {                                                                                 // V
            Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(InvalidateVisual));
        }
    
        protected override void OnRender(DrawingContext drawingContext)
        {
            if(!_position.HasValidLayout) return; // with the current setup this *should* always be true. check anyway
            var pos = _position.GetCharacterRect(LogicalDirection.Forward).TopLeft;
            pos += new Vector(0, -10); //reposition so it's on top of the line
            drawingContext.DrawText(_formattedText,pos);
        }
    }