Crea un editor di chords di chitarra in WPF (da RichTextBox?)

Lo scopo principale dell’applicazione su cui sto lavorando in WPF è consentire l’editing e di conseguenza la stampa di testi di canzoni con accordi di chitarra su di esso.

Probabilmente hai visto gli accordi anche se non suoni nessuno strumento. Per darti un’idea, assomiglia a questo:

E E6 I know I stand in line until you E E6 F#m BF#m B think you have the time to spend an evening with me 

Ma invece di questo brutto carattere mono spaziatura voglio il font Times New Roman con crenatura per entrambi i testi e gli accordi (accordi in carattere grassetto). E voglio che l’utente sia in grado di modificarlo.

Questo scenario non sembra supportato per RichTextBox . Questi sono alcuni dei problemi che non so come risolvere:

  • Gli accordi hanno le loro posizioni fisse su un certo carattere nel testo dei testi (o più in generale in TextPointer della linea dei testi). Quando l’utente modifica i testi, voglio che l’accordo rimanga sul carattere giusto. Esempio:

.

 E E6 I know !!!SOME TEXT REPLACED HERE!!! in line until you 
  • Line wrapping: 2 linee (1 ° con accordi e 2 con testo) sono logicamente una riga quando si tratta di avvolgere. Quando una parola passa alla riga successiva, anche tutti gli accordi che si trovano su di esso dovrebbero essere inclusi. Anche quando la corda avvolge la parola che è finita si avvolge. Esempio:

.

 E E6 think you have the time to spend an F#m BF#m B evening with me 
  • Gli accordi dovrebbero rimanere nel giusto carattere anche quando gli accordi sono troppo vicini l’uno all’altro. In questo caso, uno spazio extra viene automaticamente inserito nella linea dei testi. Esempio:

.

  F#m E6 ...you have the ti me to spend... 
  • Supponiamo che io abbia una linea di testi Ta VA e accordi su A Voglio che il testo sia simile Kering giusto non come inserisci la descrizione dell'immagine qui . La seconda immagine non è crenata tra V e A Le linee arancioni servono solo a visualizzare l’effetto (ma contrassegnano x offset in cui verrà posizionato l’accordo). Il codice utilizzato per produrre il primo campione è Ta VA e per il secondo campione Ta VA .

Qualche idea su come ottenere RichTextBox per farlo? O c’è un modo migliore per farlo in WPF? Posso sottoclass l’aiuto Inline o Run ? Qualsiasi idea, hack, TextPointer magic, codice o link ad argomenti correlati sono i benvenuti.


Modificare:

Sto esplorando 2 direzioni principali per risolvere questo problema, ma entrambi portano a un altro problema, quindi faccio una nuova domanda:

  1. Cercando di trasformare RichTextBox in editor di accordi – Date un’occhiata a Come posso creare una sottoclass di class Inline? .
  2. Costruisci un nuovo editor da componenti separati come il TextBox ecc. Di Panel come suggerito nella risposta HB . Ciò richiederebbe molto codice e comporterebbe anche problemi (irrisolti) seguenti:

    • I componenti cambieranno la loro larghezza / altezza in base alla posizione del layout (rimozione dello spazio bianco all’inizio della linea, ecc.)
    • La crenatura dovrà essere inserita manualmente ai limiti dei componenti.
    • Come rendere RichTextBox simile a TextBlock? (non si conosce l’elegante trucco / soluzione alternativa)

Modifica # 2

La risposta di alta qualità di Markus Hütter mi ha dimostrato che molto di più può essere fatto con RichTextBox quindi mi aspettavo che stavo cercando di modificarlo per le mie esigenze. Ho avuto il tempo di esplorare la risposta nei dettagli solo ora. Markus potrebbe essere il mago RichTextBox Ho bisogno di aiutarmi con questo, ma ci sono alcuni problemi irrisolti con la sua soluzione pure:

  1. Questa applicazione si baserà su testi “meravigliosamente” stampati. L’objective principale è che il testo sia perfetto dal punto di vista tipografico. Quando gli accordi sono troppo vicini l’uno all’altro o addirittura si sovrappongono, Markus suggerisce di aggiungere in modo iterativo spazi aggiuntivi prima della sua posizione finché la loro distanza è sufficiente. In realtà è necessario che l’utente possa impostare la distanza minima tra 2 accordi. Quella distanza minima dovrebbe essere onorata e non superata finché necessario. Gli spazi non sono abbastanza granulari – una volta che ho aggiunto l’ultimo spazio necessario, probabilmente renderò il divario più ampio del necessario – il che renderà il documento ‘cattivo’ Non penso che possa essere accettato. Avrei bisogno di inserire lo spazio della larghezza personalizzata .
  2. Potrebbero esserci linee senza accordi (solo testo) o anche linee senza testo (solo accordi). Quando LineHeight è impostato su 25 o un altro valore fisso per l’intero documento, le linee senza accordi avranno “linee vuote” sopra di esse. Quando ci sono solo accordi e nessun testo, non ci sarà spazio per loro.

Ci sono altri problemi minori ma penso di poterli risolvere o li considero non importanti. Comunque penso che la risposta di Markus sia davvero preziosa – non solo per mostrarmi la strada da percorrere ma anche come dimostrazione di un modello generale di utilizzo di RichTextBox con l’adorner.

Non posso darti alcun aiuto concreto ma in termini di architettura devi cambiare il tuo layout da questo

le linee succhiano

A questa

regola dei glifi

Tutto il resto è un hack. La tua unità / glifo deve diventare una coppia di accordi di parole.


Edit: Sono stato ingannato con un ItemsControl basato su modelli e funziona fino ad un certo punto, quindi potrebbe essere interessante.

                      
 private readonly ObservableCollection _sheetData = new ObservableCollection(); public ObservableCollection 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; } } } 

Inizialmente alcuni glifi dovrebbero essere aggiunti alla raccolta, altrimenti non ci sarà alcun campo di input (questo può essere evitato con ulteriori templating, ad esempio utilizzando un datatrigger che mostra un campo se la collezione è vuota).

Perfezionarlo richiede un sacco di lavoro aggiuntivo come lo styling dei TextBox, l'aggiunta di interruzioni di riga scritte (al momento si interrompe solo quando il pannello di wrap lo rende), supportando la selezione su più caselle di testo, ecc.

Soooo, mi sono divertito un po ‘qui. Ecco come appare:

catturare

Il testo è completamente modificabile, al momento gli accordi non lo sono (ma questa sarebbe un’estensione semplice).

questo è lo xaml:

       

e questo è il codice:

 public partial class MainWindow { public MainWindow() { InitializeComponent(); const string input = "E E6\nI know I stand in line until you\nE E6 F#m BF#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; } } } } 

usando questo Adorner:

 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)TextViewUpdated).Target, ((Action)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); } } 

questo sta usando un decoratore come suggerito da David, ma so che è difficile trovare un modo per andare là fuori. Questo probabilmente perché non ce n'è. Avevo passato ore prima in Reflector a cercare di trovare quell'esatto evento che segnala che il layout del documento di stream è stato scoperto.

Non sono sicuro che la chiamata del dispatcher nel costruttore sia effettivamente necessaria, ma l'ho lasciata per essere a prova di proiettile. (Ne avevo bisogno perché nella mia configurazione il RichTextBox non era ancora stato mostrato).

Ovviamente questo richiede molto più codice, ma questo ti darà un inizio. Avrai voglia di giocare con il posizionamento e così via.

Per ottenere il posizionamento giusto se due mascherine sono troppo vicine e si stanno sovrapponendo ti suggerirei in qualche modo di tenere traccia di quale dispositivo di decorazione viene prima e vedere se quello corrente si sovrappone. quindi, ad esempio, puoi inserire iterativamente uno spazio prima di _position -TextPointer.

Se in seguito decidi, vuoi che anche gli accordi siano modificabili, puoi semplicemente disegnare il testo in OnRender avere un intero VisualTree sotto l'adorner. ( ecco un esempio di un decoratore con un ContentControl in basso). Attenzione però che devi gestire ArrangeOveride per posizionare correttamente l'Adorner con _position CharacterRect.