using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace ScreenTranslator
{
static class Program
{
[STAThread]
static void Main(string[] args)
{
// Сборщику нужен эталонный config.json рядом с exe, а держать его в репозитории
// нельзя — файл правится пользователем и обрастает личными настройками.
// Ключ создаёт его из вшитого умолчания и сразу выходит, без окна и трея.
foreach (var a in args)
if (string.Equals(a, "--write-config", StringComparison.OrdinalIgnoreCase))
{
string err;
AppConfig.Load(out err); // создаёт файл, если его нет
return;
}
bool created;
using (var mutex = new Mutex(true, "Local\\ScreenTranslator.SingleInstance", out created))
{
if (!created)
{
MessageBox.Show("Переводчик экрана уже запущен — значок в трее.",
"Переводчик экрана", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApp());
GC.KeepAlive(mutex);
}
}
}
/// Приложение живёт в трее и просыпается по горячей клавише.
internal class TrayApp : ApplicationContext
{
const int HK_SCREEN = 1, HK_REGION = 2;
AppConfig _cfg;
OcrService _ocr;
OcrService _ocrOther; // модель другого языка — чтобы не переводить надписи на нём
TranslationCache _cache;
TranslationChain _chain;
readonly NotifyIcon _tray;
readonly HotkeyWindow _hotkeys;
OverlayForm _overlay;
CancellationTokenSource _cts;
bool _busy;
public TrayApp()
{
_tray = new NotifyIcon
{
Icon = MakeIcon(),
Text = "Переводчик экрана",
Visible = true
};
_tray.DoubleClick += delegate { Start(false); };
_hotkeys = new HotkeyWindow();
_hotkeys.Pressed += OnHotkey;
LoadConfig(true);
BuildMenu();
}
// ---------------- конфигурация ----------------
void LoadConfig(bool firstRun)
{
string err;
_cfg = AppConfig.Load(out err);
if (_cache != null) _cache.Save();
_cache = _cfg.Cache ? new TranslationCache(_cfg) : null;
_chain = new TranslationChain(_cfg, _cache);
_ocr = OcrService.Create(_cfg.OcrLanguage);
_ocrOther = _cfg.DetectForeign && _ocr != null ? OcrService.CreateOther(_ocr.LanguageTag) : null;
if (_ocr == null)
{
Balloon("OCR недоступен",
"В Windows не установлена ни одна модель распознавания. Параметры → Время и язык → " +
"Язык и регион → у нужного языка «Компоненты языка» → Распознавание текста.",
ToolTipIcon.Error);
}
else if (!string.Equals(_ocr.LanguageTag, _cfg.OcrLanguage, StringComparison.OrdinalIgnoreCase))
{
Balloon("Модель OCR заменена",
"Модели «" + _cfg.OcrLanguage + "» нет, используется «" + _ocr.LanguageTag +
"». Доступны: " + string.Join(", ", OcrService.AvailableLanguages()),
ToolTipIcon.Warning);
}
RegisterHotkeys();
if (err != null) Balloon("Настройки", err, ToolTipIcon.Warning);
else if (firstRun)
{
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
Balloon("Переводчик экрана запущен",
_cfg.HotkeyTranslate + " — перевести экран, " + _cfg.HotkeyRegion + " — область.",
ToolTipIcon.Info);
}
}
void RegisterHotkeys()
{
_hotkeys.UnregisterAll();
string bad = null;
if (!_hotkeys.Register(HK_SCREEN, _cfg.HotkeyTranslate)) bad = _cfg.HotkeyTranslate;
if (!_hotkeys.Register(HK_REGION, _cfg.HotkeyRegion))
bad = bad == null ? _cfg.HotkeyRegion : bad + " и " + _cfg.HotkeyRegion;
if (bad != null)
Balloon("Горячая клавиша занята",
"Не удалось назначить " + bad + " — комбинацию уже держит другая программа. " +
"Поменяйте её в config.json.", ToolTipIcon.Warning);
}
void BuildMenu()
{
var m = new ContextMenuStrip();
m.Items.Add("Перевести экран\t" + _cfg.HotkeyTranslate, null, delegate { Start(false); });
m.Items.Add("Перевести область\t" + _cfg.HotkeyRegion, null, delegate { Start(true); });
m.Items.Add(new ToolStripSeparator());
m.Items.Add("Проверить переводчики…", null, delegate { ProbeEngines(); });
m.Items.Add("Открыть config.json", null, delegate
{
try { Process.Start(new ProcessStartInfo(AppConfig.Path_) { UseShellExecute = true }); }
catch (Exception ex) { Balloon("Не открылось", ex.Message, ToolTipIcon.Error); }
});
m.Items.Add("Перечитать настройки", null, delegate
{
LoadConfig(false);
BuildMenu();
Balloon("Настройки перечитаны",
"Движки: " + string.Join(" → ", _chain.EngineNames) + ". OCR: " +
(_ocr == null ? "нет" : _ocr.LanguageTag), ToolTipIcon.Info);
});
m.Items.Add(new ToolStripSeparator());
m.Items.Add("Выход", null, delegate { ExitThread(); });
_tray.ContextMenuStrip = m;
}
void OnHotkey(int id)
{
if (id == HK_SCREEN) Start(false);
else if (id == HK_REGION) Start(true);
}
// ---------------- основной сценарий ----------------
void Start(bool region)
{
// повторное нажатие при открытом оверлее — закрыть, это же и «отмена»
if (_overlay != null && !_overlay.IsDisposed)
{
_overlay.Close();
return;
}
if (_busy) return;
if (_ocr == null)
{
Balloon("OCR недоступен", "Нет модели распознавания текста, перевод невозможен.", ToolTipIcon.Error);
return;
}
RunAsync(region);
}
async void RunAsync(bool region)
{
_busy = true;
var sw = Stopwatch.StartNew();
CapturedFrame frame = null;
try
{
var area = region || string.Equals(_cfg.CaptureScope, "all", StringComparison.OrdinalIgnoreCase)
? Native.VirtualScreen()
: Native.MonitorUnderCursor();
frame = ScreenCapture.Capture(area);
if (region)
{
var sel = RegionSelector.Select(frame);
if (sel.IsEmpty) { frame.Image.Dispose(); return; }
var crop = ScreenCapture.Crop(frame, sel);
frame.Image.Dispose();
frame = crop;
}
_cts = new CancellationTokenSource();
var ct = _cts.Token;
_overlay = new OverlayForm(_cfg, frame.Image, frame.Area);
_overlay.CancelRequested += delegate { try { _cts.Cancel(); } catch { } };
_overlay.Show();
_overlay.Refresh(); // кадр должен появиться до того, как начнётся распознавание
var lines = await _ocr.RecognizeAsync(frame, _cfg.OcrScale);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
var sampler = new FrameSampler(frame);
var blocks = Blocks.Group(lines, _cfg.MinChars, _cfg.SkipGarbage, sampler, _cfg.MinWords);
if (_cfg.DetectForeign && _ocrOther != null && blocks.Count > 0)
{
_overlay.SetBlocks(blocks, "Проверка языка…");
var otherLines = await _ocrOther.RecognizeAsync(frame, _cfg.OcrScale);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
int before = blocks.Count;
Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(_cfg.SourceLang));
if (blocks.Count < before)
Debug.WriteLine("отброшено как чужой язык: " + (before - blocks.Count));
}
if (blocks.Count == 0)
{
_overlay.SetBlocks(blocks, "Текст не найден");
_overlay.SetStatus("Текст не найден — Esc, чтобы закрыть", false);
return;
}
Blocks.ComputeGrowLimits(blocks, lines, new Size(frame.Width, frame.Height));
foreach (var b in blocks)
{
Color bg, fg;
sampler.Sample(b.Rect, out bg, out fg);
b.Bg = bg; b.Fg = fg;
}
_overlay.SetBlocks(blocks, "Перевод: " + blocks.Count + " фрагм. …");
var sources = blocks.Select(b => b.Source).ToArray();
var translated = await _chain.TranslateAsync(sources, ct);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
int ok = 0;
for (int i = 0; i < blocks.Count; i++)
{
var t = translated[i];
if (string.IsNullOrWhiteSpace(t)) { blocks[i].Failed = true; continue; }
blocks[i].Translated = t;
ok++;
}
sw.Stop();
string status = ok > 0
? string.Format("{0} · {1} из {2} · {3:0.0} с",
_chain.LastUsed ?? "?", ok, blocks.Count, sw.ElapsedMilliseconds / 1000.0)
: "Не удалось перевести: " + (_chain.LastError ?? "нет доступного движка");
_overlay.Refresh(blocks, status, false);
if (ok == 0)
Balloon("Перевод не удался", _chain.LastError ?? "все движки недоступны", ToolTipIcon.Error);
}
catch (Exception ex)
{
if (_overlay != null && !_overlay.IsDisposed) _overlay.Close();
Balloon("Ошибка", ex.Message, ToolTipIcon.Error);
}
finally
{
if (frame != null) frame.Dispose();
if (_cache != null) _cache.Save();
_busy = false;
}
}
async void ProbeEngines()
{
_tray.Text = "Переводчик экрана — проверяю движки…";
var sb = new StringBuilder();
try
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)))
{
var list = await _chain.ProbeAsync(cts.Token);
foreach (var s in list)
sb.AppendLine(string.Format("{0,-16} {1}",
s.Name, s.Ok ? "ок, " + s.Ms + " мс" : "не отвечает — " + s.Error));
}
}
catch (Exception ex) { sb.AppendLine("Проверка прервана: " + ex.Message); }
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
MessageBox.Show(sb.ToString() +
"\nПорядок использования задаётся полем \"chain\" в config.json.",
"Доступность переводчиков", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
void Balloon(string title, string text, ToolTipIcon icon)
{
if (!_cfg.ShowTrayBalloonOnError && icon != ToolTipIcon.Info) return;
try
{
_tray.BalloonTipTitle = title;
_tray.BalloonTipText = text;
_tray.BalloonTipIcon = icon;
_tray.ShowBalloonTip(6000);
}
catch { }
}
/// Иконка рисуется в коде, чтобы приложение оставалось одним файлом без ресурсов.
static Icon MakeIcon()
{
var bmp = new Bitmap(32, 32);
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
using (var b = new LinearGradientBrush(new Rectangle(0, 0, 32, 32),
Color.FromArgb(38, 84, 160), Color.FromArgb(22, 42, 88), 45f))
g.FillEllipse(b, 0, 0, 31, 31);
using (var f = new Font("Segoe UI", 15f, FontStyle.Bold, GraphicsUnit.Pixel))
{
g.DrawString("A", f, Brushes.White, new PointF(2.5f, 4f));
g.DrawString("Я", f, new SolidBrush(Color.FromArgb(120, 200, 255)), new PointF(13.5f, 12f));
}
}
IntPtr h = bmp.GetHicon();
try { return (Icon)Icon.FromHandle(h).Clone(); }
finally { DestroyIcon(h); bmp.Dispose(); }
}
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)]
static extern bool DestroyIcon(IntPtr handle);
protected override void Dispose(bool disposing)
{
if (disposing)
{
try { if (_cts != null) _cts.Cancel(); } catch { }
if (_cache != null) _cache.Save();
if (_hotkeys != null) _hotkeys.Dispose();
if (_tray != null) { _tray.Visible = false; _tray.Dispose(); }
}
base.Dispose(disposing);
}
}
/// Невидимое окно, принимающее WM_HOTKEY: глобальные клавиши нужны и когда фокус
/// в игре или чужом окне, поэтому обычные события формы тут не годятся.
internal class HotkeyWindow : NativeWindow, IDisposable
{
readonly System.Collections.Generic.List _ids = new System.Collections.Generic.List();
public event Action Pressed;
public HotkeyWindow() { CreateHandle(new CreateParams()); }
public bool Register(int id, string combo)
{
uint mods, vk; string display;
if (!AppConfig.ParseHotkey(combo, out mods, out vk, out display)) return false;
if (!Native.RegisterHotKey(Handle, id, mods | Native.MOD_NOREPEAT, vk)) return false;
_ids.Add(id);
return true;
}
public void UnregisterAll()
{
foreach (var id in _ids) Native.UnregisterHotKey(Handle, id);
_ids.Clear();
}
protected override void WndProc(ref Message m)
{
if (m.Msg == Native.WM_HOTKEY)
{
var h = Pressed;
if (h != null) h(m.WParam.ToInt32());
}
base.WndProc(ref m);
}
public void Dispose()
{
UnregisterAll();
DestroyHandle();
}
}
}