Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .agent/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ Record meaningful technical decisions here. Use one entry per decision.

## Entries

- Date: 2026-09-01
- Decision: Add a dedicated batch-lyric popup to the Hand, Note, and MultiSelect piano-roll context menus. Initialize it from the earliest selected note through the end of the active voice part, and apply one undoable multi-note lyric command either sequentially or to the selected-note snapshot.
- Rationale: A focused text editor supports fast paste-and-replace workflows while preserving the current note selection and Core's existing undo/validation behavior. Keeping parsing and scope selection in the Mobile ViewModel avoids changes to upstream-derived Core.
- Alternatives considered: Extend the single-note lyric navigator; route the workflow through the generic batch-edit catalog; mutate note lyrics directly.
- Impacted areas: Mobile piano-roll context actions, batch-lyric popup/ViewModel, and localization. OpenUtau.Core is unchanged.

- Date: 2026-09-01
- Decision: Reuse Core's upstream `SplitLyrics.Split` and `SplitLyrics.Join` for the batch-lyric editor instead of maintaining a Mobile-only delimiter expression.
- Rationale: The upstream algorithm handles Unicode text elements, automatic CJK/Hiragana/Katakana/Hangul segmentation, contracted Japanese kana, empty lyrics, whitespace-containing lyrics, and quoted groups with matching round-trip serialization.
- Alternatives considered: Expand the Mobile regular expression; fork and maintain a second parser in the Mobile project.
- Impacted areas: Mobile batch-lyric initialization, parsing, and input guidance. Core remains unchanged because the synchronized utility already exists.

- Date: 2026-08-31
- Decision: Expose clipboard text writes through an injectable `IClipboardService` in `ServiceHub`, backed by Avalonia's current `TopLevel` clipboard. Keep error-detail selection and copy orchestration in the Mobile UI/ViewModel layer.
- Rationale: The service follows the existing cross-platform capability pattern, keeps ViewModels independent of window/platform APIs, and uses Avalonia's native clipboard bridge across desktop, mobile, and browser hosts without duplicating platform code.
Expand Down
12 changes: 12 additions & 0 deletions OpenUtauMobile/Assets/Lang/Strings.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1756,4 +1756,16 @@
<data name="ErrorDialog.CopyFailed" xml:space="preserve">
<value>Failed to copy error information</value>
</data>
<data name="BulkLyricEdit.Title" xml:space="preserve">
<value>Batch edit lyrics</value>
</data>
<data name="BulkLyricEdit.Watermark" xml:space="preserve">
<value>Enter lyrics; use quotes to keep spaces or character groups together</value>
</data>
<data name="BulkLyricEdit.SelectedOnly" xml:space="preserve">
<value>Apply to selected notes only</value>
</data>
<data name="BulkLyricEdit.Apply" xml:space="preserve">
<value>Apply</value>
</data>
</root>
12 changes: 12 additions & 0 deletions OpenUtauMobile/Assets/Lang/Strings.ja.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1749,4 +1749,16 @@
<data name="ErrorDialog.CopyFailed" xml:space="preserve">
<value>エラー情報をコピーできませんでした</value>
</data>
<data name="BulkLyricEdit.Title" xml:space="preserve">
<value>歌詞を一括編集</value>
</data>
<data name="BulkLyricEdit.Watermark" xml:space="preserve">
<value>歌詞を入力します。空白や文字列をまとめるには引用符を使用します</value>
</data>
<data name="BulkLyricEdit.SelectedOnly" xml:space="preserve">
<value>選択したノートにのみ適用</value>
</data>
<data name="BulkLyricEdit.Apply" xml:space="preserve">
<value>適用</value>
</data>
</root>
12 changes: 12 additions & 0 deletions OpenUtauMobile/Assets/Lang/Strings.ru.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1711,4 +1711,16 @@
<data name="ErrorDialog.CopyFailed" xml:space="preserve">
<value>Не удалось скопировать информацию об ошибке</value>
</data>
<data name="BulkLyricEdit.Title" xml:space="preserve">
<value>Пакетное изменение текста</value>
</data>
<data name="BulkLyricEdit.Watermark" xml:space="preserve">
<value>Введите текст; кавычки сохраняют пробелы и группы символов</value>
</data>
<data name="BulkLyricEdit.SelectedOnly" xml:space="preserve">
<value>Применить только к выбранным нотам</value>
</data>
<data name="BulkLyricEdit.Apply" xml:space="preserve">
<value>Применить</value>
</data>
</root>
12 changes: 12 additions & 0 deletions OpenUtauMobile/Assets/Lang/Strings.uk.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1711,4 +1711,16 @@
<data name="ErrorDialog.CopyFailed" xml:space="preserve">
<value>Не вдалося скопіювати інформацію про помилку</value>
</data>
<data name="BulkLyricEdit.Title" xml:space="preserve">
<value>Пакетне редагування тексту</value>
</data>
<data name="BulkLyricEdit.Watermark" xml:space="preserve">
<value>Введіть текст; лапки зберігають пробіли та групи символів</value>
</data>
<data name="BulkLyricEdit.SelectedOnly" xml:space="preserve">
<value>Застосувати лише до вибраних нот</value>
</data>
<data name="BulkLyricEdit.Apply" xml:space="preserve">
<value>Застосувати</value>
</data>
</root>
12 changes: 12 additions & 0 deletions OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1756,4 +1756,16 @@
<data name="ErrorDialog.CopyFailed" xml:space="preserve">
<value>复制报错信息失败</value>
</data>
<data name="BulkLyricEdit.Title" xml:space="preserve">
<value>批量编辑歌词</value>
</data>
<data name="BulkLyricEdit.Watermark" xml:space="preserve">
<value>输入歌词;使用双引号保留含空格或需组合的内容</value>
</data>
<data name="BulkLyricEdit.SelectedOnly" xml:space="preserve">
<value>仅应用到选中的音符</value>
</data>
<data name="BulkLyricEdit.Apply" xml:space="preserve">
<value>应用</value>
</data>
</root>
71 changes: 71 additions & 0 deletions OpenUtauMobile/Controls/BulkLyricEditPopup.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:OpenUtauMobile.ViewModels"
mc:Ignorable="d"
x:Class="OpenUtauMobile.Controls.BulkLyricEditPopup"
d:DataContext="{d:DesignInstance Type=vm:BulkLyricEditViewModel, IsDesignTimeCreatable=False}"
x:DataType="vm:BulkLyricEditViewModel"
Classes="PopupDialogRoot"
MinHeight="280">

<Border Classes="PopupDialogSurface">
<DockPanel>
<Border Classes="PopupTitleBar"
DockPanel.Dock="Top">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{DynamicResource BulkLyricEdit.Title}"
Classes="PopupTitle" />
<Button Grid.Column="1"
Classes="PopupCloseBtn"
Command="{Binding CancelCommand}"
ToolTip.Tip="{DynamicResource Common.Cancel}">
<Path Width="14"
Height="14"
Stretch="Uniform"
Fill="{DynamicResource Sem.Color.OnSurface}"
Data="M19,6.41 L17.59,5 L12,10.59 L6.41,5 L5,6.41 L10.59,12 L5,17.59 L6.41,19 L12,13.41 L17.59,19 L19,17.59 L13.41,12 Z" />
</Button>
</Grid>
</Border>

<Border Padding="16"
Background="{DynamicResource Sem.Color.Surface}">
<Grid RowDefinitions="*,Auto,Auto"
RowSpacing="12">
<TextBox x:Name="LyricsInput"
Grid.Row="0"
Text="{Binding LyricsText, Mode=TwoWay}"
PlaceholderText="{DynamicResource BulkLyricEdit.Watermark}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="144"
VerticalContentAlignment="Top"
Padding="12,8"
CornerRadius="8"
FontSize="14"
BorderThickness="1" />

<CheckBox Grid.Row="1"
Content="{DynamicResource BulkLyricEdit.SelectedOnly}"
IsChecked="{Binding ApplyToSelectedNotesOnly, Mode=TwoWay}"
IsEnabled="{Binding HasSelectedNotes}" />

<Grid Grid.Row="2"
ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="8">
<Button Grid.Column="1"
Command="{Binding CancelCommand}"
Classes="Secondary"
Content="{DynamicResource Common.Cancel}" />
<Button Grid.Column="2"
Command="{Binding ApplyCommand}"
Classes="Primary"
Content="{DynamicResource BulkLyricEdit.Apply}" />
</Grid>
</Grid>
</Border>
</DockPanel>
</Border>
</UserControl>
33 changes: 33 additions & 0 deletions OpenUtauMobile/Controls/BulkLyricEditPopup.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;

namespace OpenUtauMobile.Controls;

public partial class BulkLyricEditPopup : PopupDialogControl
{
protected override PopupDialogWidthPreset WidthPreset => PopupDialogWidthPreset.Wide;

public BulkLyricEditPopup()
{
InitializeComponent();
}

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
Dispatcher.UIThread.Post(FocusAndSelectLyrics);
}

private void FocusAndSelectLyrics()
{
TextBox? input = this.FindControl<TextBox>("LyricsInput");
if (input == null)
{
return;
}

input.Focus();
input.SelectAll();
}
}
94 changes: 94 additions & 0 deletions OpenUtauMobile/ViewModels/BulkLyricEditViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive;
using OpenUtau.Core;
using OpenUtau.Core.Ustx;
using OpenUtau.Core.Util;
using ReactiveUI;
using ReactiveUI.Fody.Helpers;

namespace OpenUtauMobile.ViewModels;

/// <summary>
/// 歌词批量编辑弹窗 ViewModel。
/// </summary>
public sealed class BulkLyricEditViewModel : PopupViewModelBase, IDisposable
{
private readonly UVoicePart _part;
private readonly List<UNote> _partNotes;
private readonly List<UNote> _selectedNotes;
private readonly int _startNoteIndex;

[Reactive]
public string LyricsText { get; set; }

[Reactive]
public bool ApplyToSelectedNotesOnly { get; set; }

public bool HasSelectedNotes => _selectedNotes.Count > 0;

public ReactiveCommand<Unit, Unit> CancelCommand { get; }

public ReactiveCommand<Unit, Unit> ApplyCommand { get; }

public BulkLyricEditViewModel(UVoicePart part, IReadOnlyCollection<UNote> selectedNotes)
{
_part = part;
_partNotes = [.. part.notes];
_selectedNotes =
[
.. _partNotes
.Where(selectedNotes.Contains)
];

UNote? firstSelectedNote = _selectedNotes.FirstOrDefault();
_startNoteIndex = firstSelectedNote == null ? 0 : _partNotes.IndexOf(firstSelectedNote);
LyricsText = SplitLyrics.Join(_partNotes.Skip(_startNoteIndex).Select(note => note.lyric));

CancelCommand = ReactiveCommand.Create(OnCancel);
ApplyCommand = ReactiveCommand.Create(OnApply);
}

private void OnCancel()
{
RaiseClose(null);
}

private void OnApply()
{
List<string> lyrics = SplitLyrics.Split(LyricsText);
if (lyrics.Count == 0)
{
RaiseClose(null);
return;
}

IEnumerable<UNote> candidates = ApplyToSelectedNotesOnly
? _selectedNotes
: _partNotes.Skip(_startNoteIndex);
UNote[] notes = [.. candidates.Take(lyrics.Count)];
if (notes.Length > 0)
{
string[] appliedLyrics = [.. lyrics.Take(notes.Length)];
DocManager.Inst.StartUndoGroup();
DocManager.Inst.ExecuteCmd(new ChangeNoteLyricCommand(_part, notes, appliedLyrics));
DocManager.Inst.EndUndoGroup();
}

RaiseClose(null);
}

public override void RequestBack()
{
RaiseClose(null);
}

public void Dispose()
{
CancelCommand.Dispose();
ApplyCommand.Dispose();
GC.SuppressFinalize(this);
}

}
28 changes: 28 additions & 0 deletions OpenUtauMobile/ViewModels/PianoRollViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2191,11 +2191,16 @@ private void RebuildPianoRollContextActions()
switch (EditMode)
{
case PianoRollEditMode.Hand:
if (EditingVoicePart != null)
{
items.Add(CreateBulkLyricEditAction());
}
break;

case PianoRollEditMode.Note:
if (EditingVoicePart != null)
{
items.Add(CreateBulkLyricEditAction());
// 批量编辑
items.Add(CreateBatchEditAction());
}
Expand Down Expand Up @@ -2253,6 +2258,7 @@ private void RebuildPianoRollContextActions()
});
if (EditingVoicePart != null)
{
items.Add(CreateBulkLyricEditAction());
items.Add(CreateBatchEditAction());
}
if (hasNoteSelection)
Expand Down Expand Up @@ -2372,6 +2378,28 @@ private void RebuildPianoRollContextActions()
PianoRollContextActions = items;
}

private ContextActionItem CreateBulkLyricEditAction()
{
return new ContextActionItem
{
Icon = PackIconPhosphorIconsKind.TextAa,
Tip = L.S("BulkLyricEdit.Title"),
Command = ReactiveCommand.CreateFromTask(ShowBulkLyricEditPopupAsync),
};
}

private async Task ShowBulkLyricEditPopupAsync()
{
UVoicePart? part = EditingVoicePart;
if (part == null || part.notes.Count == 0)
{
return;
}

BulkLyricEditViewModel viewModel = new(part, SelectedNotes.ToList());
await PopupService.Show<object>(new BulkLyricEditPopup(), viewModel);
}

private ContextActionItem CreateBatchEditAction()
{
return new ContextActionItem
Expand Down
Loading