محرر رمز Android: الجزء 1



قبل الانتهاء من العمل على محرر الكود الخاص بي ، خطوت على أشعل النار عدة مرات ، وربما فكمت عشرات التطبيقات المماثلة ، وفي هذه السلسلة من المقالات سأتحدث عن ما تعلمته ، وما هي الأخطاء التي يمكن تجنبها ، والعديد من الأشياء الأخرى المثيرة للاهتمام.



المقدمة



مرحبا بالجميع! انطلاقا من العنوان ، من الواضح تماما ما سيكون عليه ، ولكن لا يزال يتعين علي إدخال بضع كلمات خاصة بي قبل الانتقال إلى الشفرة.



قررت تقسيم المقالة إلى جزأين ، في الجزء الأول سنكتب تدريجيًا لتمييز بناء الجملة المحسّن وترقيم السطر ، وفي الجزء الثاني سنضيف إكمال التعليمات البرمجية وتسليط الضوء على الخطأ.



أولاً ، لنضع قائمة بما يجب أن يتمكن محررنا من القيام به:



  • تسليط الضوء على بناء الجملة
  • إظهار ترقيم الأسطر
  • إظهار خيارات الإكمال التلقائي (سأخبرك في الجزء الثاني)
  • تسليط الضوء على أخطاء بناء الجملة (سأقول في الجزء الثاني)


هذه ليست القائمة الكاملة للميزات التي يجب أن يمتلكها محرر الكود الحديث ، ولكن هذا ما أريد أن أتحدث عنه في هذه السلسلة القصيرة من المقالات.



MVP - محرر نص بسيط



في هذه المرحلة ، لا ينبغي أن يكون هناك أي مشاكل - تمتد EditTextإلى الشاشة الكاملة ، gravityأشر backgroundإلى شفاف لإزالة الشريط من الأسفل ، حجم الخط ، لون النص ، إلخ. أحب أن أبدأ بالجزء المرئي ، لذلك أصبح من الأسهل بالنسبة لي أن أفهم ما هو مفقود في التطبيق ، وما هي التفاصيل التي لا تزال تستحق العمل عليها.



في هذه المرحلة ، قمت أيضًا بتحميل / حفظ الملفات في الذاكرة. لن أعطي الرمز ، هناك وفرة من الأمثلة على العمل مع الملفات على الإنترنت.



تسليط الضوء على تركيب



بمجرد قراءة متطلبات المحرر ، حان الوقت للانتقال إلى الأكثر إثارة للاهتمام.



من الواضح ، للتحكم في العملية برمتها - للرد على المدخلات ورسم أرقام الأسطر ، سيتعين علينا كتابة CustomViewوراثة من EditText. نلجأ TextWatcherإلى الاستماع إلى التغييرات في النص ونلغي الطريقة afterTextChangedالتي سنتصل من خلالها بالطريقة المسؤولة عن التمييز:



class TextProcessor @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = R.attr.editTextStyle
) : EditText(context, attrs, defStyleAttr) {

    private val textWatcher = object : TextWatcher {
        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
        override fun afterTextChanged(s: Editable?) {
            syntaxHighlight()
        }
    }

    private fun syntaxHighlight() {
        //    
    }
}


س: لماذا نستخدمه TextWatcherكمتغير ، لأنه يمكنك تنفيذ الواجهة مباشرة في الفصل؟

ج: يحدث أن TextWatcherلدينا طريقة تتعارض مع طريقة موجودة في TextView:



//  TextWatcher
fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)

//  TextView
fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)


كل من هذه الأساليب لها نفس الاسم ونفس الحجج، ويبدو أن لديها نفس المعنى، ولكن المشكلة هي أن onTextChangedذ طريقة سيتم TextViewيسمى جنبا إلى جنب مع onTextChangedذ TextWatcher. إذا وضعنا السجلات في نص الطريقة ، فسوف نرى ما onTextChangedيسمى مرتين:





هذا أمر بالغ الأهمية إذا كنا نخطط لإضافة وظيفة تراجع / إعادة. قد نحتاج أيضًا إلى لحظة لا يعمل فيها المستمعون ، حيث يمكننا مسح المكدس مع تغييرات النص. لا نريد أن نكون قادرين على الضغط على تراجع بعد فتح ملف جديد والحصول على نص مختلف تمامًا. على الرغم من أن هذه المقالة لن تتحدث عن التراجع / الإعادة ، فمن المهم النظر في هذه النقطة.



وفقًا لذلك ، لتجنب مثل هذا الموقف ، يمكنك استخدام طريقة تثبيت النص الخاصة بك بدلاً من الطريقة القياسية setText:



fun processText(newText: String) {
    removeTextChangedListener(textWatcher)
    // undoStack.clear()
    // redoStack.clear()
    setText(newText)
    addTextChangedListener(textWatcher)
}


لكن عد إلى الإبراز.



تحتوي العديد من لغات البرمجة على شيء رائع مثل RegEx ، وهي أداة تسمح لك بالبحث عن تطابقات نصية في سلسلة. أوصي على الأقل بالتعرف على ميزاته الأساسية ، لأنه عاجلاً أم آجلاً قد يحتاج أي مبرمج إلى "سحب" جزء من المعلومات من النص.



الآن من المهم بالنسبة لنا أن نعرف شيئين فقط:



  1. يحدد النمط ما نحتاج إلى العثور عليه بالضبط في النص
  2. سيعمل Matcher من خلال النص في محاولة للعثور على ما أشرنا إليه في النموذج


ربما لم يصفها بشكل صحيح تمامًا ، ولكن هذه هي الطريقة التي تعمل بها.



نظرًا لأنني أكتب محررًا لجافا سكريبت ، فإليك نمطًا صغيرًا بكلمات رئيسية للغة:



private val KEYWORDS = Pattern.compile(
    "\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b"
)


بالطبع ، يجب أن يكون هناك الكثير من الكلمات هنا ، ونحتاج أيضًا إلى أنماط للتعليقات والخطوط والأرقام وما إلى ذلك. لكن مهمتي هي توضيح المبدأ الذي يمكنك من خلاله العثور على المحتوى المطلوب في النص.



بعد ذلك ، باستخدام Matcher ، سنراجع النص بالكامل ونضع الامتدادات:



private fun syntaxHighlight() {
    val matcher = KEYWORDS.matcher(text)
    matcher.region(0, text.length)
    while (matcher.find()) {
        text.setSpan(
            ForegroundColorSpan(Color.parseColor("#7F0055")),
            matcher.start(),
            matcher.end(),
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
        )
    }
}


اسمحوا لي أن أشرح: نحصل على كائن Matcher من Pattern ، ونشير إلى منطقة البحث بالأحرف (وفقًا لذلك ، من 0 إلى text.lengthهذا هو النص بالكامل). بعد ذلك ، matcher.find()ستعود المكالمة trueإذا تم العثور على تطابق في النص ، وبمساعدة المكالمة matcher.start()، matcher.end()سنحصل على موضع بداية ونهاية المباراة في النص. بمعرفة هذه البيانات ، يمكننا استخدام الطريقة setSpanلتلوين مناطق معينة من النص.



هناك العديد من أنواع الامتدادات ، ولكنها تُستخدم عادةً لإعادة طلاء النص ForegroundColorSpan.



دعنا نبدأ!



تتوافق النتيجة مع التوقعات بالضبط حتى نبدأ في تحرير ملف كبير (في لقطة الشاشة ملف ~ 1000 سطر)



والحقيقة هي أن الطريقة setSpanتعمل ببطء ، وتحمل بشكل كبير مؤشر ترابط واجهة المستخدم ، وبالنظر إلى أن الطريقة afterTextChangedيتم استدعاؤها بعد كل حرف تم إدخاله ، فإنه يصبح عذاب واحد.



إيجاد حل



أول شيء يتبادر إلى الذهن هو نقل عملية ثقيلة إلى سلسلة خلفية. لكن العملية الثقيلة هنا تتم في setSpanجميع أنحاء النص ، وليس الموسم العادي. (أعتقد أنه لا يمكنك شرح لماذا لا يمكنك الاتصال setSpanمن موضوع الخلفية).



بعد قليل من البحث عن المقالات المميزة ، نكتشف أنه إذا أردنا تحقيق السلاسة ، علينا فقط إبراز الجزء المرئي من النص.



بالضبط! لنفعلها اذا! فقط كيف؟



الاقوي



على الرغم من أنني ذكرت أننا لا نهتم إلا بأداء الطريقة setSpan، ما زلت أوصي بوضع عمل RegEx في سلسلة مؤشرات خلفية لتحقيق أقصى قدر من السلاسة.



نحتاج إلى فصل يعالج كل النص في الخلفية ويعرض قائمة من الامتدادات.

لن أعطي تنفيذًا محددًا ، ولكن إذا كان أي شخص مهتمًا ، فأنا أستخدم التطبيق الذي AsyncTaskيعمل من أجله ThreadPoolExecutor. (نعم ، نعم ، AsyncTask في عام 2020)



الشيء الرئيسي بالنسبة لنا هو تنفيذ المنطق التالي:



  1. و beforeTextChanged توقف العمل أن يوزع النص
  2. في أننا afterTextChanged بدء العمل الذي يوزع النص
  3. في نهاية عملها ، يجب أن تقوم المهمة بإرجاع قائمة الامتدادات TextProcessor، والتي بدورها ستبرز الجزء المرئي فقط


ونعم ، سنكتب الامتدادات الخاصة بنا أيضًا:



data class SyntaxHighlightSpan(
    private val color: Int,
    val start: Int,
    val end: Int
) : CharacterStyle() {

    //     italic, ,   
    override fun updateDrawState(textPaint: TextPaint?) {
        textPaint?.color = color
    }
}


وبالتالي ، يتحول رمز المحرر إلى شيء مثل هذا:



الكثير من التعليمات البرمجية
class TextProcessor @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = R.attr.editTextStyle
) : EditText(context, attrs, defStyleAttr) {

    private val textWatcher = object : TextWatcher {
        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
            cancelSyntaxHighlighting()
        }
        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
        override fun afterTextChanged(s: Editable?) {
            syntaxHighlight()
        }
    }

    private var syntaxHighlightSpans: List<SyntaxHighlightSpan> = emptyList()

    private var javaScriptStyler: JavaScriptStyler? = null

    fun processText(newText: String) {
        removeTextChangedListener(textWatcher)
        // undoStack.clear()
        // redoStack.clear()
        setText(newText)
        addTextChangedListener(textWatcher)
        // syntaxHighlight()
    }

    private fun syntaxHighlight() {
        javaScriptStyler = JavaScriptStyler()
        javaScriptStyler?.setSpansCallback { spans ->
            syntaxHighlightSpans = spans
            updateSyntaxHighlighting()
        }
        javaScriptStyler?.runTask(text.toString())
    }

    private fun cancelSyntaxHighlighting() {
        javaScriptStyler?.cancelTask()
    }

    private fun updateSyntaxHighlighting() {
        //     
    }
}




نظرًا لأنني لم أظهر التنفيذ المحدد للمعالجة في الخلفية ، فلنتخيل أننا كتبنا JavaScriptStylerواحدة معينة ستفعل كل شيء في الخلفية الذي فعلناه من قبل في مؤشر ترابط واجهة المستخدم - يتم تشغيله عبر النص بالكامل بحثًا عن التطابقات وملء قائمة الامتدادات ، وفي النهاية من عملها سيعيد النتيجة إلى setSpansCallback. في هذه اللحظة ، سيتم إطلاق طريقة ستمر updateSyntaxHighlightingعبر قائمة الامتدادات وتعرض فقط تلك التي تظهر حاليًا على الشاشة.



كيف تعرف النص الذي يقع في المنطقة المرئية؟



سأشير إلى هذه المقالة ، حيث يقترح المؤلف استخدام شيء مثل هذا:



val topVisibleLine = scrollY / lineHeight
val bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height -  View
val lineStart = layout.getLineStart(topVisibleLine)
val lineEnd = layout.getLineEnd(bottomVisibleLine)


ويعمل! الآن دعنا ننتقل topVisibleLineذلك bottomVisibleLineلفصل أساليب وإضافة بضع عمليات فحص إضافية، في حال سارت الامور بشكل سيء:



طرق جديدة
private fun getTopVisibleLine(): Int {
    if (lineHeight == 0) {
        return 0
    }
    val line = scrollY / lineHeight
    if (line < 0) {
        return 0
    }
    return if (line >= lineCount) {
        lineCount - 1
    } else line
}

private fun getBottomVisibleLine(): Int {
    if (lineHeight == 0) {
        return 0
    }
    val line = getTopVisibleLine() + height / lineHeight + 1
    if (line < 0) {
        return 0
    }
    return if (line >= lineCount) {
        lineCount - 1
    } else line
}




آخر شيء يجب القيام به هو مراجعة القائمة الناتجة من الامتدادات ولون النص:



for (span in syntaxHighlightSpans) {
    val isInText = span.start >= 0 && span.end <= text.length
    val isValid = span.start <= span.end
    val isVisible = span.start in lineStart..lineEnd
            || span.start <= lineEnd && span.end >= lineStart
    if (isInText && isValid && isVisible)) {
        text.setSpan(
            span,
            if (span.start < lineStart) lineStart else span.start,
            if (span.end > lineEnd) lineEnd else span.end,
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
        )
    }
}


لا تندهش من الرعب if'، لكنه يتحقق فقط مما إذا كان الامتداد من القائمة يقع في المنطقة المرئية.



حسنا ، هل تعمل؟



يعمل ، ولكن عند تحرير النص ، لا يتم تحديث الامتدادات ، يمكنك إصلاح الموقف بمسح النص من جميع الامتدادات قبل تراكب جديدة:



// :  getSpans   core-ktx
val textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)
for (span in textSpans) {
    text.removeSpan(span)
}


تعذر آخر - بعد إغلاق لوحة المفاتيح ، يظل جزء من النص غير مظللًا ، نقوم بإصلاحه:



override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    updateSyntaxHighlighting()
}


الشيء الرئيسي هو عدم نسيان الإشارة adjustResizeفي البيان.



التمرير



بالحديث عن التمرير ، سأشير إلى هذه المقالة مرة أخرى . يقترح المؤلف الانتظار 500 مللي ثانية بعد نهاية التمرير ، وهو ما يتعارض مع شعوري بالجمال. لا أريد الانتظار حتى يتم تحميل الإضاءة الخلفية ، أريد أن أرى النتيجة على الفور.



يجادل المؤلف أيضًا بأن تشغيل المحلل اللغوي بعد كل بكسل "تم تمريره" مكلف ، وأنا أتفق تمامًا مع هذا (بشكل عام ، أوصي بأن تقرأ مقاله بالكامل ، إنه صغير ، ولكن هناك العديد من الأشياء المثيرة للاهتمام). لكن الحقيقة هي أن لدينا بالفعل قائمة جاهزة من الامتدادات ، ولا نحتاج إلى تشغيل المحلل اللغوي.



يكفي استدعاء الطريقة المسؤولة عن تحديث الإضاءة الخلفية:



override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {
    super.onScrollChanged(horiz, vert, oldHoriz, oldVert)
    updateSyntaxHighlighting()
}


ترقيم الخط



إذا أضفنا حرفًا آخر إلى الترميز ، TextViewفسيكون من الصعب ربطهما ببعضهما البعض (على سبيل المثال ، لتحديث حجم النص بشكل متزامن) ، وإذا كان لدينا ملف كبير ، فسنضطر إلى تحديث النص بالكامل بأرقام بعد إدخال كل حرف ، وهو أمر ليس رائعًا. لذلك، سوف نستخدم أي وسيلة القياسية CustomView- الاعتماد على Canvasفي onDrawوسريع وليس من الصعب.



أولاً ، دعنا نحدد ما سنرسمه:



  • أرقام الأسطر
  • الخط العمودي الذي يفصل بين حقل الإدخال وأرقام الخطوط


يجب عليك أولاً الحساب والتعيين على paddingيسار المحرر حتى لا يكون هناك تعارض مع النص المطبوع.



للقيام بذلك ، سنكتب دالة لتحديث المسافة البادئة قبل الرسم:



تحديث المسافة البادئة
private var gutterWidth = 0
private var gutterDigitCount = 0
private var gutterMargin = 4.dpToPx() //     

...

private fun updateGutter() {
    var count = 3
    var widestNumber = 0
    var widestWidth = 0f

    gutterDigitCount = lineCount.toString().length
    for (i in 0..9) {
        val width = paint.measureText(i.toString())
        if (width > widestWidth) {
            widestNumber = i
            widestWidth = width
        }
    }
    if (gutterDigitCount >= count) {
        count = gutterDigitCount
    }
    val builder = StringBuilder()
    for (i in 0 until count) {
        builder.append(widestNumber.toString())
    }
    gutterWidth = paint.measureText(builder.toString()).toInt()
    gutterWidth += gutterMargin
    if (paddingLeft != gutterWidth + gutterMargin) {
        setPadding(gutterWidth + gutterMargin, gutterMargin, paddingRight, 0)
    }
}




التفسير:



أولاً ، نجد عدد الأسطر في EditText(لا يجب الخلط بينه وبين عدد " \n" في النص) ، ونأخذ عدد الأحرف من هذا الرقم. على سبيل المثال ، إذا كان لدينا 100 سطر ، فسيكون المتغير gutterDigitCountيساوي 3 ، لأن هناك 3 أحرف بالضبط في 100. ولكن لنفترض أن لدينا سطر واحد فقط ، مما يعني أن المسافة البادئة المكونة من حرف واحد ستظهر بصريًا بشكل صغير ، ولهذا نستخدم متغير العد لتعيين الحد الأدنى للمسافة البادئة المعروضة من 3 أحرف ، حتى إذا كان لدينا أقل من 100 سطر من الرمز.



كان هذا الجزء هو الأكثر إرباكًا ، ولكن إذا قرأته بعناية عدة مرات (بالنظر إلى الرمز) ، فسيصبح كل شيء واضحًا.



بعد ذلك ، نقوم بتعيين المسافة البادئة بعد حساب widestNumberو widestWidth.



لنبدأ الرسم



لسوء الحظ ، إذا أردنا استخدام التفاف نص Android القياسي على سطر جديد ، فسيتعين علينا استحضار هذا الأمر ، والذي سيستغرق منا الكثير من الوقت والمزيد من التعليمات البرمجية ، وهو ما سيكون كافيًا لمقالة كاملة ، وبالتالي ، من أجل تقليل وقتك (ووقت مشرف الخلل) ، سنقوم بتمكين أفقي التمرير بحيث تذهب جميع الخطوط واحدة تلو الأخرى:



setHorizontallyScrolling(true)


حسنًا ، الآن يمكنك البدء في الرسم ، دعنا نعلن المتغيرات من النوع Paint:



private val gutterTextPaint = Paint() //  
private val gutterDividerPaint = Paint() //  


initقم بتعيين لون النص ولون الفاصل في مكان ما في الكتلة. من المهم أن تتذكر أنه إذا قمت بتغيير خط النص ، فيجب تطبيق الخط Paintيدويًا ، لذلك أنصحك بتجاوز الطريقة setTypeface. وبالمثل مع حجم النص.



ثم نتجاوز الطريقة onDraw:



override fun onDraw(canvas: Canvas?) {
    updateGutter()
    super.onDraw(canvas)
    var topVisibleLine = getTopVisibleLine()
    val bottomVisibleLine = getBottomVisibleLine()
    val textRight = (gutterWidth - gutterMargin / 2) + scrollX
    while (topVisibleLine <= bottomVisibleLine) {
        canvas?.drawText(
            (topVisibleLine + 1).toString(),
            textRight.toFloat(),
            (layout.getLineBaseline(topVisibleLine) + paddingTop).toFloat(),
            gutterTextPaint
        )
        topVisibleLine++
    }
    canvas?.drawLine(
        (gutterWidth + scrollX).toFloat(),
        scrollY.toFloat(),
        (gutterWidth + scrollX).toFloat(),
        (scrollY + height).toFloat(),
        gutterDividerPaint
    )
}


نحن ننظر إلى النتيجة



هذا يبدوا جيدا.



ماذا فعلنا في onDraw؟ قبل استدعاء superالطريقة ، قمنا بتحديث المسافة البادئة ، وبعد ذلك قدمنا ​​الأرقام فقط في المنطقة المرئية ، وفي النهاية رسمنا خطًا رأسيًا يفصل بين ترقيم الأسطر بصريًا من محرر الشفرة.



بالنسبة للجمال ، يمكنك أيضًا إعادة رسم المسافة البادئة بلون مختلف ، وإبراز الخط الذي يقع عليه المؤشر بشكل مرئي ، ولكن سأترك ذلك حسب تقديرك.



خاتمة



في هذه المقالة كتبنا محرر كود مستجيبًا مع تمييز بناء الجملة وترقيم السطر ، وفي الجزء التالي سنضيف استكمالًا مناسبًا للتعليمات البرمجية وتمييز بناء الجملة مباشرةً أثناء التحرير.



سأترك أيضًا رابطًا لمصادر محرر الشفرة الخاص بي على GitHub ، حيث لن تجد فقط تلك الميزات التي وصفتها في هذه المقالة ، ولكن أيضًا العديد من الميزات الأخرى التي تم تركها دون اهتمام.



حدث: الجزء الثاني خارج بالفعل ،



اطرح أسئلة واقترح مواضيع للمناقشة ، لأنني كنت قد فاتني شيء.



شكرا!



All Articles